Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3477b22
fix(pairing): drop the BT bond when a peer is forgotten
claude Aug 25, 2026
f56ab2f
feat(cache): namespace phone-specific caches per peer
claude Aug 25, 2026
64fb21c
feat(multi-peer): pair a second laptop, and split active from connected
claude Aug 25, 2026
e196c68
feat(multi-peer): "Switch device" on the laptop
claude Aug 25, 2026
38a5642
feat(multi-peer): phone-side Seeking machinery
claude Aug 26, 2026
22057f3
fix(calllog): stop putting SQL LIMIT in the provider sortOrder
claude Aug 26, 2026
e466b57
feat(multi-peer): PEER_HANDOFF frame — tell a displaced peer it lost …
claude Aug 26, 2026
b40767a
feat(multi-peer): the other half of PEER_HANDOFF — phone→laptop RELEASE
claude Aug 26, 2026
88351a5
fix(sms): window in Kotlin, not with SQL LIMIT in the sortOrder
claude Aug 26, 2026
41e9021
docs: communication scheme and multi-peer design
claude Aug 26, 2026
92ff4bc
fix(share): don't crash the app on an oversized file share
claude Aug 26, 2026
6820f83
fix(share): queue a multi-file share instead of silently dropping mos…
claude Aug 26, 2026
ab4d620
fix(ble): advertise when ACL-connected but sessionless
claude Aug 26, 2026
ca41abb
fix(wifi-direct): only switch networks when it actually helps
claude Aug 26, 2026
a70a115
fix(files): make an incoming pull idempotent per content token
claude Aug 26, 2026
95142cc
docs: design for browsing the phone's files from the desktop
claude Aug 26, 2026
5a4c9f3
fix(ui): make the phone's laptop card show — and offer — the right de…
claude Aug 27, 2026
19d989f
feat(ui): show every paired laptop, and switch to a named one
claude Aug 27, 2026
7f98298
fix(ble): drop a peer's CCCD subscriptions when its link dies
X-Ryl669 Aug 28, 2026
c124642
fix(handoff): open a shared page once, not every 12 seconds forever
claude Aug 28, 2026
b5ba77d
fix(rebase): reconcile this branch's APIs with upstream's new callers
claude Sep 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 143 additions & 51 deletions android/app/src/main/java/com/vortex/a3/core/ble/Advertiser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,13 @@ class Advertiser(private val context: Context) {
// ~1.5s screen-on — MIUI throttles background advertising hard,
// and a LOW_LATENCY request lands in a faster throttle tier).
// Re-evaluated at every 60s token rotation.
// `seeking` has to be its own term: [fastModeProvider] means "link is
// DOWN and was lost recently", but a seek deliberately keeps the
// current link UP (seek before release), so it evaluates false exactly
// when we most want the dense schedule — the user is walking to
// another machine right now. This is the first rung of the §D5 ladder.
val advertiseMode = if (payload.flags.isPairable ||
seeking ||
fastModeProvider?.invoke() == true
) {
AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY
Expand Down Expand Up @@ -174,87 +180,158 @@ class Advertiser(private val context: Context) {
}

/**
* Start trusted-presence advertising with a rotating token derived
* from [prs] per spec §7.3. The token rotates every
* [rotationWindowSec] seconds so passive observers cannot link
* sightings across windows.
* True while a peer session is live. When it is, the presence loop
* advertises **nothing**: the session itself is the proof of presence, so
* a beacon on top of it is pure battery cost. Wired by VortexStack to the
* GATT server's connection state.
*
* The supplied [scope] owns the rotation job. Cancel the scope (or
* call [stop]) to end advertising.
* This is the biggest saving in the whole state machine — the phone is
* connected most of the time, and it used to beacon 24/7 regardless. It is
* safe for the laptop's proximity auto-lock precisely because that treats
* "authenticated session OR token-validated advertisement" as presence,
* and on a drop [kickRotation] puts us back on air immediately.
*/
fun startTrustedPresence(
prs: ByteArray,
var linkedProvider: (() -> Boolean)? = null

/**
* The PRS of every peer whose token we may advertise, most-recently-used
* first. Returning several enables token multiplexing (below).
*/
var presencePeersProvider: (() -> List<ByteArray>)? = null

/**
* Set while the user is looking for a *different* laptop ("Switch").
* Forces advertising even though a session is live, so the other laptop
* can see us without dropping the one we are on first.
*/
@Volatile
var seeking: Boolean = false

/**
* Presence + seeking loop (spec §7.3, design doc §D1/§D5).
*
* One advertising set, driven through three phases:
*
* * **Active** — a session is live and we are not seeking: advertise
* nothing, and re-check often enough that a missed disconnect callback
* self-heals in seconds rather than a full rotation window.
* * **Seeking / Dark** — no session (or the user pressed Switch):
* advertise `TRUSTED_PRESENCE`. [fastModeProvider] already supplies the
* ladder — LOW_LATENCY while the link was recently lost, BALANCED after
* that. BALANCED is the floor rather than silence on purpose: the
* laptop's proximity confirmation scan is short, and a present-but-
* silent phone would be mistaken for one that walked away.
*
* **Token multiplexing.** The advertisement carries exactly one 8-byte
* token and the ADV_IND is already at the legacy 31-byte ceiling, so N
* remembered laptops cannot be addressed at once. With more than one peer
* the loop cycles them, dwelling [MULTIPLEX_DWELL_MS] on each, so any of
* them sees us within N × dwell — a few seconds, which is nothing on a
* deliberate walk-up. With a single peer it does NOT cycle: restarting the
* advertiser needlessly churns the RPA and costs battery, so the common
* case keeps exactly the old one-advertise-per-bucket behaviour.
*/
fun startPresenceLoop(
scope: CoroutineScope,
rotationWindowSec: Long = 60L,
/** True while a peer is connected over GATT. See the rotation loop. */
isConnected: () -> Boolean = { false },
onError: (String) -> Unit = {},
) {
require(prs.size == 32) { "PRS must be 32 bytes" }
// Cancel any existing rotation before starting a new one. Stop
// current adv too so we start the new mode cleanly.
presenceJob?.cancel()
stop()
val prsCopy = prs.copyOf()
presenceJob = scope.launch {
// Consecutive start failures. Each bucket retries regardless
// Consecutive start failures. Each round retries regardless
// (restarting an advertiser is cheap and the radio may have just
// come back), but a persistent failure must not stay silent —
// the phone is INVISIBLE over BLE while this fails. Surface it
// once via onError after a few misses, then again only if it
// keeps failing after a recovery.
var consecFails = 0
var wasSilent = false
while (isActive) {
val nowSec = System.currentTimeMillis() / 1000
val bucket = Presence.currentBucket(nowSec, rotationWindowSec)
val token = Presence.deriveToken(prsCopy, bucket)
// Do NOT restart the advertiser while a peer is connected.
//
// Stopping and starting an advertising set makes Android hand
// out a fresh resolvable private address. Doing that every 60 s
// (and again on every characteristic subscribe) meant the
// laptop's cached address was ALWAYS dead by the time it tried
// to reconnect, so its "connect straight to the last address"
// fast path could never once succeed — every reconnect paid a
// full 15 s scan, and six such failures in a row used to make
// the laptop power-cycle its whole Bluetooth adapter.
//
// The rotation exists to stop a passive observer linking our
// advertisements over time. A connected peer is not that
// observer: it already knows exactly who we are, and while the
// link is up nobody is scanning for us. So rotate when it
// matters — between sessions — and hold still while connected.
if (isConnected() && activePayload != null) {
Log.d(TAG, "presence rotation held: peer connected (keeping this RPA)")
val intoBucket = nowSec % rotationWindowSec
withTimeoutOrNull((rotationWindowSec - intoBucket + 5L) * 1000) {
rotationKick.receive()
val linked = linkedProvider?.invoke() == true
if (linked && !seeking) {
if (!wasSilent) {
Log.i(TAG, "presence: session live — advertising suspended")
wasSilent = true
}
stop()
// Short re-check, not a full bucket: if a disconnect
// callback is ever dropped we would otherwise stay dark
// (and invisible) for up to a whole rotation window.
withTimeoutOrNull(ACTIVE_RECHECK_MS) { rotationKick.receive() }
continue
}
stop()
startWith(AdvPayload.trustedPresence(token)) { result ->
if (wasSilent) {
Log.i(TAG, "presence: link down or seeking — advertising resumed")
wasSilent = false
}

val peers = presencePeersProvider?.invoke().orEmpty()
if (peers.isEmpty()) {
stop()
withTimeoutOrNull(ACTIVE_RECHECK_MS) { rotationKick.receive() }
continue
}

val nowSec = System.currentTimeMillis() / 1000
val bucket = Presence.currentBucket(nowSec, rotationWindowSec)
val onStart: (StartResult) -> Unit = { result ->
when (result) {
is StartResult.Started -> consecFails = 0
is StartResult.Failed -> {
consecFails++
Log.w(TAG, "trusted-presence advertise failed (${consecFails}x): ${result.reason}")
Log.w(TAG, "presence advertise failed (${consecFails}x): ${result.reason}")
if (consecFails == PRESENCE_FAIL_ALERT_AT) onError(result.reason)
}
}
}
// Sleep until ~5s past the next bucket boundary so we
// refresh just inside the new window — OR until a kick
// (connect/disconnect edge) asks for an immediate
// re-advertise with a re-evaluated mode. Receivers
// tolerate ±1 bucket so a small drift is fine.
val secondsIntoBucket = nowSec % rotationWindowSec
val sleepSec = rotationWindowSec - secondsIntoBucket + 5L
withTimeoutOrNull(sleepSec * 1000) { rotationKick.receive() }

if (peers.size == 1) {
stop()
startWith(AdvPayload.trustedPresence(Presence.deriveToken(peers[0], bucket)), onStart)
// Sleep until ~5s past the next bucket boundary so we
// refresh just inside the new window — OR until a kick
// (connect/disconnect edge) asks for an immediate
// re-advertise with a re-evaluated mode. Receivers
// tolerate ±1 bucket so a small drift is fine.
val sleepSec = rotationWindowSec - (nowSec % rotationWindowSec) + 5L
withTimeoutOrNull(sleepSec * 1000) { rotationKick.receive() }
} else {
// Multiplex one pass over the peers, then re-evaluate the
// phase from the top (the session may have come back, or
// the peer set changed).
for (prs in peers) {
if (!isActive) break
stop()
startWith(AdvPayload.trustedPresence(Presence.deriveToken(prs, bucket)), onStart)
val kicked = withTimeoutOrNull(MULTIPLEX_DWELL_MS) { rotationKick.receive() }
// A kick means the phase changed — abandon the pass
// instead of finishing a cycle nobody is waiting for.
if (kicked != null) break
}
}
}
}
}

/**
* Single-peer entry point, kept for the pairing-completion path which has
* exactly one peer and no service running yet.
*/
fun startTrustedPresence(
prs: ByteArray,
scope: CoroutineScope,
rotationWindowSec: Long = 60L,
/** True while a peer is connected over GATT. See the rotation loop. */
isConnected: () -> Boolean = { false },
onError: (String) -> Unit = {},
) {
require(prs.size == 32) { "PRS must be 32 bytes" }
val only = listOf(prs.copyOf())
presencePeersProvider = { only }
startPresenceLoop(scope, rotationWindowSec, onError)
}

fun stop() {
val cb = activeCallback ?: return
try {
Expand Down Expand Up @@ -290,9 +367,24 @@ class Advertiser(private val context: Context) {
companion object {
private const val TAG = "VortexAdv"

/** Consecutive trusted-presence start failures before [startTrustedPresence]'s
/** Consecutive trusted-presence start failures before [startPresenceLoop]'s
* onError fires (the loop itself keeps retrying every bucket). */
private const val PRESENCE_FAIL_ALERT_AT = 3

/** How long each peer's token stays on air during multiplexing.
*
* Long enough for a scanning laptop to catch several advertising
* events (LOW_LATENCY ≈ 100 ms, BALANCED ≈ 250 ms), short enough that
* N peers all get seen within a few seconds. Also the floor on how
* often we restart the advertising set, which re-randomises the RPA —
* cheaper dwells would inflate the laptop's BlueZ device cache and
* feed the stale-RPA connect wedge. */
private const val MULTIPLEX_DWELL_MS = 1_500L

/** Re-check interval while advertising is suspended (session live) or
* there is nothing to advertise. Bounds how long a *dropped*
* disconnect callback can leave us silent and therefore invisible. */
private const val ACTIVE_RECHECK_MS = 15_000L
}
}

Expand Down
17 changes: 17 additions & 0 deletions android/app/src/main/java/com/vortex/a3/core/ble/Frame.kt
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,13 @@ object FrameType {
const val PHONE_FILES: Byte = 0x4F

const val FRAG: Byte = 0x4E
/** Session-ownership handoff (design doc §D4). A device may TRUST many
* peers but is ACTIVE with exactly one; this frame is how the two sides
* agree which. `sub` carries the kind ([FrameSub.HANDOFF_RELEASE] etc.),
* the AEAD payload an optional UTF-8 successor name for the UI.
* Additive: both sides log-and-ignore unknown frame types, so a peer
* without this build is unaffected. Mirrors Rust `ty::PEER_HANDOFF`. */
const val PEER_HANDOFF: Byte = 0x4F
const val ERROR: Byte = 0x7F
}

Expand All @@ -160,6 +167,16 @@ object FrameSub {
const val PONG: Byte = 0x02
const val ECHO_REQUEST: Byte = 0x01
const val ECHO_RESPONSE: Byte = 0x02
/** [FrameType.PEER_HANDOFF] kinds. Mirror Rust `ty::sub::HANDOFF_*`. */
/** "You are no longer my active peer" — sent by the side handing ownership
* over, so the receiver stops presenting itself as connected instead of
* finding out on next contact. */
const val HANDOFF_RELEASE: Byte = 0x01
/** Refused: another peer is already active. Explicit because silence is
* indistinguishable from packet loss and invites a retry loop. */
const val HANDOFF_BUSY: Byte = 0x02
/** Request to become the active peer. */
const val HANDOFF_CLAIM: Byte = 0x03
}

/** Header size in bytes. */
Expand Down
Loading
Loading