diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 5eae147..d1b3f5a 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -37,6 +37,14 @@
+
+
diff --git a/android/app/src/main/java/com/vortex/a3/core/ble/Advertiser.kt b/android/app/src/main/java/com/vortex/a3/core/ble/Advertiser.kt
index 131a929..fcaae9e 100644
--- a/android/app/src/main/java/com/vortex/a3/core/ble/Advertiser.kt
+++ b/android/app/src/main/java/com/vortex/a3/core/ble/Advertiser.kt
@@ -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
@@ -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)? = 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 {
@@ -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
}
}
diff --git a/android/app/src/main/java/com/vortex/a3/core/ble/Frame.kt b/android/app/src/main/java/com/vortex/a3/core/ble/Frame.kt
index c94df71..c83a6be 100644
--- a/android/app/src/main/java/com/vortex/a3/core/ble/Frame.kt
+++ b/android/app/src/main/java/com/vortex/a3/core/ble/Frame.kt
@@ -152,6 +152,36 @@ 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`. */
+ // 0x54, not 0x4F — see the note on the Rust side: upstream took 0x4F for
+ // PHONE_FILES, and PEER_HANDOFF is the one that has never shipped.
+ const val PEER_HANDOFF: Byte = 0x54
+ /** Ranged-filesystem request. `sub` carries the op
+ * ([com.vortex.a3.core.fs.FsOp]), the payload a JSON request — plus a
+ * binary byte tail for WRITE.
+ *
+ * BIDIRECTIONAL and symmetric: this phone both serves these (so the
+ * laptop can browse its storage) and sends them (so it can browse the
+ * laptop's). Neither the frame nor its handler names a side. See
+ * `docs/design/file-browsing.md`. Mirrors Rust `ty::FS_REQ`. */
+ const val FS_REQ: Byte = 0x50
+ /** Successful non-data reply — directory page, stat, open result, write
+ * ack. Carries `FsReply` JSON. Mirrors Rust `ty::FS_META`. */
+ const val FS_META: Byte = 0x51
+ /** Read result: `[id u32 BE][offset u64 BE][flags u8][bytes]`. Binary, not
+ * JSON: base64 would cost 33% on the protocol's hottest path. Mirrors
+ * Rust `ty::FS_DATA`. */
+ const val FS_DATA: Byte = 0x52
+ /** A definite failure for one request id (`FsErr` JSON). Every failing op
+ * answers with one — a file manager blocked on a read that will never be
+ * answered is this feature's worst outcome, so silence is never valid.
+ * Mirrors Rust `ty::FS_ERR`. */
+ const val FS_ERR: Byte = 0x53
const val ERROR: Byte = 0x7F
}
@@ -160,6 +190,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. */
diff --git a/android/app/src/main/java/com/vortex/a3/core/ble/GattServer.kt b/android/app/src/main/java/com/vortex/a3/core/ble/GattServer.kt
index 50b0ea4..1fb413f 100644
--- a/android/app/src/main/java/com/vortex/a3/core/ble/GattServer.kt
+++ b/android/app/src/main/java/com/vortex/a3/core/ble/GattServer.kt
@@ -169,11 +169,37 @@ class GattServer(
@Volatile var onCallControlReceived: (peerStaticPub: ByteArray, jsonBytes: ByteArray) -> Unit =
{ _, _ -> }
+ /**
+ * Invoked when the laptop WRITES a PEER_HANDOFF frame: it has handed
+ * session ownership to a different phone, so we are no longer its active
+ * peer. `kind` is the [FrameSub] code, `successorName` the display name of
+ * whoever took over (empty when unknown — the kind carries the meaning).
+ */
+ @Volatile var onPeerHandoffReceived: (
+ peerStaticPub: ByteArray,
+ kind: Byte,
+ successorName: String,
+ ) -> Unit = { _, _, _ -> }
+
/** Invoked when the laptop WRITES a NOTES_SYNC chunk (`[total][idx][data]`):
* reassembled + LWW-merged into the local notes store. */
@Volatile var onNotesSyncReceived: (peerStaticPub: ByteArray, chunk: ByteArray) -> Unit =
{ _, _ -> }
+ /** Invoked when the laptop WRITES an FS_REQ (0x50): a filesystem op against
+ * the folders this phone shares. `op` is the frame's `sub` byte — unlike
+ * every other frame here the type alone does not say what was asked, so it
+ * has to travel through. The handler must not block: it runs on the GATT
+ * callback thread, and a document provider can stall for seconds. */
+ @Volatile var onFsRequest: (peerStaticPub: ByteArray, op: Byte, payload: ByteArray) -> Unit =
+ { _, _, _ -> }
+
+ /** Invoked for a reply to a request WE sent: FS_META / FS_DATA / FS_ERR.
+ * Replies carry no `sub` — the frame type says which of the three it is
+ * and the request id inside correlates it. */
+ @Volatile var onFsReply: (peerStaticPub: ByteArray, frameType: Byte, payload: ByteArray) -> Unit =
+ { _, _, _ -> }
+
/** Invoked when a device (the laptop) ENABLES notifications on the
* AUDIO_SIGNAL characteristic — i.e. the BLE notify path just became
* deliverable. VortexStack uses this to flush any notifications that
@@ -362,7 +388,16 @@ class GattServer(
* Called under `synchronized(cipher)` from [sealAndNotify], so a
* fragment burst can't interleave with another sealed frame. */
fun sendAudioSignal(device: BluetoothDevice, frame: Frame): Boolean {
- val budget = ((deviceMtu[device.address] ?: 23) - 3).coerceAtLeast(1)
+ // ATT_MTU-3 is the transport limit, but NOT the only one: the GATT
+ // attribute value itself caps at 512 bytes, and
+ // `notifyCharacteristicChanged` THROWS above that rather than
+ // truncating. On a 517 MTU the two disagree — budget 514 > 512 — so
+ // every fragment we built at full budget crashed the app from a worker
+ // thread. Observed live the first time a frame needed fragmenting on a
+ // 517-MTU link (an FS directory listing); any notification over the
+ // budget would have done it.
+ val budget = minOf((deviceMtu[device.address] ?: 23) - 3, ATT_MAX_VALUE_LEN)
+ .coerceAtLeast(1)
val encoded = frame.encode()
if (encoded.size <= budget) {
return notifyTo(device, frame, audioSignalChar, audioSignalSubscribers)
@@ -424,6 +459,15 @@ class GattServer(
Log.i(TAG, "registered audio session for peer=${peerHex.take(8)}… device=${device.address}")
}
+ /** Which peer a connected device authenticated as, or null if IK has not
+ * completed on it.
+ *
+ * The disconnect hook hands back a [BluetoothDevice], and the caller
+ * usually needs the identity behind it — an address is an RPA and means
+ * nothing on its own. */
+ fun peerPubFor(device: BluetoothDevice): ByteArray? =
+ deviceToPeerPub[device.address]?.copyOf()
+
/** Drop the audio session for a peer (call on un-trust). Safe to
* call repeatedly — the maps tolerate missing keys. */
fun forgetAudioSession(peerStaticPub: ByteArray) {
@@ -473,6 +517,10 @@ class GattServer(
logTag: String,
logSuccess: Boolean = false,
verbose: Boolean = false,
+ // Almost every frame type is self-describing and leaves this 0. FS_REQ
+ // is the exception: its op lives here, so the payload cannot be read
+ // without it.
+ sub: Byte = 0x00,
): Boolean {
val peerHex = peerStaticPub.toHex()
val device = peerToDevice[peerHex] ?: run {
@@ -501,7 +549,7 @@ class GattServer(
Log.e(TAG, "$logTag: AEAD seal failed", e)
return false
}
- sendAudioSignal(device, Frame(frameType, 0x00, ct.copyOf(n)))
+ sendAudioSignal(device, Frame(frameType, sub, ct.copyOf(n)))
}
if (notifyOk) {
if (logSuccess) Log.i(TAG, "$logTag: notified ${device.address}")
@@ -529,6 +577,41 @@ class GattServer(
fun sendNotesSyncEncrypted(peerStaticPub: ByteArray, chunkPayload: ByteArray): Boolean =
sealAndNotify(peerStaticPub, FrameType.NOTES_SYNC, chunkPayload, "sendNotesSync")
+ /** One filesystem REQUEST to the laptop (FS_REQ 0x50), for browsing the
+ * laptop's files from the phone. The op rides in the frame's `sub`. */
+ fun sendFsRequest(peerStaticPub: ByteArray, op: Byte, payload: ByteArray): Boolean =
+ sealAndNotify(peerStaticPub, FrameType.FS_REQ, payload, "sendFsRequest", sub = op)
+
+ /** One filesystem reply — FS_META (0x51), FS_DATA (0x52) or FS_ERR (0x53).
+ * Replies carry no `sub`: the frame type says which of the three this is,
+ * and the request id inside the payload correlates it. */
+ fun sendFsReply(peerStaticPub: ByteArray, frameType: Byte, payload: ByteArray): Boolean =
+ sealAndNotify(peerStaticPub, frameType, payload, "sendFsReply")
+
+ /**
+ * Session-ownership handoff (PEER_HANDOFF 0x4F) — tell [peerStaticPub] it is
+ * no longer our active peer, so its UI stops claiming a live link instead of
+ * finding out on next contact (design doc §D4).
+ *
+ * [kind] is a [FrameSub] HANDOFF_* code, carried as the FIRST PAYLOAD BYTE
+ * rather than in Frame.sub: the laptop's generic sealed-frame writer only
+ * takes a frame type, so both sides agreed on this placement.
+ * [successorName] is advisory, for the receiver's UI ("moved to ").
+ */
+ fun sendPeerHandoffEncrypted(
+ peerStaticPub: ByteArray,
+ kind: Byte,
+ successorName: String = "",
+ ): Boolean {
+ val name = successorName.toByteArray(Charsets.UTF_8)
+ val body = ByteArray(name.size + 1)
+ body[0] = kind
+ name.copyInto(body, 1)
+ return sealAndNotify(
+ peerStaticPub, FrameType.PEER_HANDOFF, body, "sendPeerHandoff", logSuccess = true,
+ )
+ }
+
/** Clipboard sync (CLIPBOARD 0x40) → peer's system clipboard. */
fun sendClipboardEncrypted(peerStaticPub: ByteArray, clipJson: ByteArray): Boolean =
sealAndNotify(peerStaticPub, FrameType.CLIPBOARD, clipJson, "sendClipboardEncrypted", logSuccess = true)
@@ -683,6 +766,16 @@ class GattServer(
} catch (e: SecurityException) {
Log.w(TAG, "notify threw for ${device.address}: ${e.message}")
false
+ } catch (e: RuntimeException) {
+ // Anything else the stack throws — an oversized value, a stale
+ // server handle — must become a failed send, not a dead app.
+ // This runs on a coroutine worker, where an escaping exception
+ // takes the whole process down, and every caller here already
+ // handles false. A 517-byte MTU once did exactly that: the
+ // fragment budget was MTU-3 while GATT caps an attribute value
+ // at 512, and the stack threw rather than truncating.
+ Log.w(TAG, "notify failed for ${device.address}: ${e.message}")
+ false
}
if (!queued) {
gate.pending = false
@@ -731,6 +824,29 @@ class GattServer(
fun hasActiveConnection(): Boolean = connectedAddrs.isNotEmpty()
+ /** The peers holding a live GATT link right now, by static public key.
+ *
+ * Only peers that have completed IK appear — an address alone is an RPA
+ * and proves nothing about identity. Used by the presence loop to decide
+ * whose token there is no point beaconing at: a live session IS the
+ * presence proof, so advertising at it is pure radio waste. */
+ fun linkedPeerPubs(): List =
+ connectedAddrs.mapNotNull { addr -> deviceToPeerPub[addr]?.copyOf() }
+
+ /**
+ * True when a peer has SUBSCRIBED to AUDIO_SIGNAL, i.e. the notify path is
+ * actually deliverable.
+ *
+ * Distinct from [hasActiveConnection], which only says some central holds
+ * an ACL link. Those come apart in practice: BlueZ owns the ACL, so it
+ * survives the laptop app being restarted or killed, leaving a connection
+ * with no Vortex session behind it. Treating that as "connected" made the
+ * phone suppress its presence advertising while being unreachable — the
+ * laptop could not find it to re-establish, and neither side broke the tie.
+ */
+ fun hasAudioSignalSubscriber(): Boolean =
+ synchronized(audioSignalSubscribers) { audioSignalSubscribers.isNotEmpty() }
+
private val callback = object : BluetoothGattServerCallback() {
override fun onMtuChanged(device: BluetoothDevice?, mtu: Int) {
// Track the negotiated ATT MTU per device: the notify payload
@@ -787,6 +903,18 @@ class GattServer(
// state is preserved; reconnect state is dead either way.)
pairingOrchestrator?.forgetDeviceOnDisconnect(device)
reconnectOrchestrator?.forgetDevice(device)
+ // A CCCD subscription dies with the link: the central never gets
+ // to write 0x0000 on its way out. Leaving the device in these
+ // sets is not merely untidy — `linkedProvider` is keyed on
+ // [hasAudioSignalSubscriber], so a phantom subscriber makes the
+ // presence loop suspend advertising FOREVER. The phone then
+ // cannot be found by the very laptop it is waiting for, and
+ // only an app restart (which calls stop()) breaks the tie.
+ // Observed live: laptop app restarted at 20:08, phone silent
+ // for the next ten hours while LAN heartbeats kept flowing.
+ pairingSubscribers.remove(device)
+ reconnectSubscribers.remove(device)
+ audioSignalSubscribers.remove(device)
try { onPeerDisconnected(device) } catch (e: Exception) {
Log.w(TAG, "onPeerDisconnected hook threw: ${e.message}")
}
@@ -942,6 +1070,14 @@ class GattServer(
Log.w(TAG, "AudioSignal WRITE: no recv cipher for $addr; drop")
return
}
+ // Allowlist of frame types this characteristic accepts.
+ // It must list every type with a dispatch arm below: a type
+ // missing here is rejected before dispatch, so its handler
+ // is dead code that looks wired. PEER_HANDOFF was exactly
+ // that — the laptop sends it through this path
+ // (cmd_pairing), the arm below handles it, and this guard
+ // silently dropped every one, so a displaced phone never
+ // learned it had lost ownership.
if (frame.type != FrameType.AUDIO_OP &&
frame.type != FrameType.NOTIFICATION &&
frame.type != FrameType.STATE &&
@@ -949,7 +1085,12 @@ class GattServer(
frame.type != FrameType.CLIPBOARD &&
frame.type != FrameType.CLIPBOARD_IMAGE &&
frame.type != FrameType.CLIPBOARD_TEXT &&
- frame.type != FrameType.NOTES_SYNC
+ frame.type != FrameType.NOTES_SYNC &&
+ frame.type != FrameType.PEER_HANDOFF &&
+ frame.type != FrameType.FS_REQ &&
+ frame.type != FrameType.FS_META &&
+ frame.type != FrameType.FS_DATA &&
+ frame.type != FrameType.FS_ERR
) {
Log.w(TAG, "AudioSignal WRITE: unexpected frame type ${frame.type}")
return
@@ -1079,6 +1220,46 @@ class GattServer(
Log.w(TAG, "onNotesSyncReceived threw: ${e.message}")
}
}
+ FrameType.FS_META, FrameType.FS_DATA, FrameType.FS_ERR -> {
+ // A reply to something we asked the laptop for.
+ try {
+ onFsReply(peerPub, frame.type, jsonBytes)
+ } catch (e: Exception) {
+ Log.w(TAG, "onFsReply threw: ${e.message}")
+ }
+ }
+ FrameType.FS_REQ -> {
+ // Laptop→phone filesystem op. The op rides in the
+ // frame's `sub`, so pass it on: FS_REQ is the one
+ // inbound type whose payload cannot be interpreted
+ // without it. Paths are not logged — they are the
+ // user's folder names.
+ try {
+ onFsRequest(peerPub, frame.sub, jsonBytes)
+ } catch (e: Exception) {
+ Log.w(TAG, "onFsRequest threw: ${e.message}")
+ }
+ }
+ FrameType.PEER_HANDOFF -> {
+ // The kind rides as the first payload byte rather
+ // than Frame.sub: the laptop's generic sealed-frame
+ // writer only takes a frame type, so both sides
+ // agree to carry it here. An empty payload is
+ // malformed — ignore rather than guess a kind.
+ if (jsonBytes.isEmpty()) {
+ Log.w(TAG, "PEER_HANDOFF with empty payload — ignored")
+ } else {
+ val kind = jsonBytes[0]
+ val name = runCatching {
+ String(jsonBytes, 1, jsonBytes.size - 1, Charsets.UTF_8)
+ }.getOrDefault("")
+ try {
+ onPeerHandoffReceived(peerPub, kind, name)
+ } catch (e: Exception) {
+ Log.w(TAG, "onPeerHandoffReceived threw: ${e.message}")
+ }
+ }
+ }
}
}
@@ -1131,6 +1312,10 @@ class GattServer(
companion object {
private const val TAG = "VortexGattSrv"
+
+ /** GATT caps an attribute value at 512 bytes regardless of the
+ * negotiated MTU, and the notify call throws above it. */
+ private const val ATT_MAX_VALUE_LEN = 512
/** How far to skip the recv nonce forward when an AUDIO_SIGNAL open
* fails, to resync past dropped BLE writes without a re-handshake
* (mirrors the laptop daemon's NONCE_RESYNC_WINDOW). */
diff --git a/android/app/src/main/java/com/vortex/a3/core/calllog/CallLogProvider.kt b/android/app/src/main/java/com/vortex/a3/core/calllog/CallLogProvider.kt
index ffe28cf..3b93ec1 100644
--- a/android/app/src/main/java/com/vortex/a3/core/calllog/CallLogProvider.kt
+++ b/android/app/src/main/java/com/vortex/a3/core/calllog/CallLogProvider.kt
@@ -93,13 +93,25 @@ class CallLogProvider(
}
}
+ /**
+ * The most recent [LIMIT] calls, newest-first.
+ *
+ * The row cap is [queryCallLog]'s Kotlin `cap`, NOT a SQL `LIMIT` appended
+ * to the sort order: the call-log provider validates `sortOrder` and throws
+ * `IllegalArgumentException("Invalid token LIMIT")` (seen on Android 16 /
+ * OnePlus), which made every read fail — silently here, and as
+ * `call_log_history: "error"` on the laptop's bulk-sync. Don't re-add it.
+ */
private fun readCallLog(): List =
- queryCallLog(null, null, "${CallLog.Calls.DATE} DESC LIMIT $LIMIT", LIMIT)
+ queryCallLog(null, null, "${CallLog.Calls.DATE} DESC", LIMIT)
/**
* Read calls NEWER than [sinceMs] (oldest-first, up to [limit]) for the
* laptop's LAN bulk-sync history backfill — the watermark twin of
* [com.vortex.a3.core.sms.SmsProvider.readHistorySince].
+ *
+ * [limit] is enforced in Kotlin, not as a SQL `LIMIT` in the sort order —
+ * same provider rejection as [readCallLog].
*/
fun readHistorySince(sinceMs: Long, limit: Int): List {
if (!hasPermission()) return emptyList()
@@ -107,7 +119,7 @@ class CallLogProvider(
return queryCallLog(
"${CallLog.Calls.DATE} > ?",
arrayOf(sinceMs.toString()),
- "${CallLog.Calls.DATE} ASC LIMIT $cap",
+ "${CallLog.Calls.DATE} ASC",
cap,
)
}
@@ -141,7 +153,7 @@ class CallLogProvider(
val dateIdx = c.getColumnIndex(CallLog.Calls.DATE)
val durIdx = c.getColumnIndex(CallLog.Calls.DURATION)
while (c.moveToNext()) {
- if (out.size >= cap) break // guard if the SQL LIMIT is ignored
+ if (out.size >= cap) break // sole row limit: see readCallLog
val id = if (idIdx >= 0) c.getString(idIdx).orEmpty() else ""
out.add(
CallLogEntry(
diff --git a/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardBlobStore.kt b/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardBlobStore.kt
index 2bbbabc..d1eeb31 100644
--- a/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardBlobStore.kt
+++ b/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardBlobStore.kt
@@ -18,7 +18,14 @@ import java.security.MessageDigest
* hashed once from a read that is then dropped.
*/
object ClipboardBlobStore {
- private const val MAX_ENTRIES = 32
+ /**
+ * Blobs kept before the oldest is evicted — and therefore the hard ceiling
+ * on how many files ONE share can deliver: the laptop pulls them one at a
+ * time over LAN, so a blob evicted before its turn is a file that silently
+ * never arrives. Callers cap their batch at this, so the two numbers cannot
+ * drift apart (see ShareReceiverActivity.MAX_SHARE_FILES).
+ */
+ const val MAX_ENTRIES = 32
// Insertion-ordered so eviction drops the oldest first.
private val blobs = LinkedHashMap ByteArray?>()
diff --git a/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardFileOut.kt b/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardFileOut.kt
index 7ada56b..b095cf8 100644
--- a/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardFileOut.kt
+++ b/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardFileOut.kt
@@ -5,66 +5,96 @@ import android.net.Uri
import android.provider.OpenableColumns
import android.util.Log
-/** A file the phone is sending to the laptop (bytes + display name + MIME). */
-data class ClipboardOutgoingFile(val bytes: ByteArray, val name: String, val mime: String)
+/** A file the phone is sending to the laptop. */
+data class ClipboardOutgoingFile(
+ /** What to read when the laptop pulls it. Held as a URI, never as bytes:
+ * the file is streamed in ranges on demand, so its size no longer has to
+ * fit in the heap. */
+ val uri: Uri,
+ val name: String,
+ val mime: String,
+ /** Best known size, or -1 when the provider will not say. Advisory only —
+ * the open is what decides. */
+ val size: Long,
+)
/**
- * Reads an arbitrary clipboard / shared `content://` URI into a
- * [ClipboardOutgoingFile] for phone→laptop FILE sync. Used by both the Quick
- * Settings quick-send and the share-sheet target. Returns null if it isn't
- * readable or exceeds the LAN size cap.
+ * Describes an arbitrary clipboard / shared `content://` URI for phone→laptop
+ * FILE sync. Used by both the Quick Settings quick-send and the share-sheet
+ * target.
+ *
+ * It no longer READS the file. The old version buffered the whole thing to
+ * compute a content hash for the token and to hand the bytes to the offer path,
+ * which is what made an 835 MB share allocate 876 MB against a 256 MB heap
+ * growth limit and throw `OutOfMemoryError` — an `Error`, so the surrounding
+ * `catch (Exception)` missed it and the process died, taking the BLE/LAN
+ * service with it. The 64 MB cap existed to keep that from happening.
+ *
+ * Now the laptop pulls the file through the ranged-read protocol
+ * ([com.vortex.a3.core.fs.FsServer]), one bounded chunk at a time, so nothing
+ * on either side holds more than a chunk and the cap is gone.
*/
object ClipboardFileReader {
- /** Mirrors the Rust `clipboard_mirror::MAX_FILE_BYTES`. */
- const val MAX_FILE_BYTES = 64L * 1024 * 1024
private const val TAG = "ClipboardFileOut"
- fun read(context: Context, uri: Uri): ClipboardOutgoingFile? {
- return try {
- readInner(context, uri)
- } catch (e: Exception) {
- Log.w(TAG, "file read failed: ${e.message}")
- null
- }
+ /** Outcome of a describe, so the caller can tell the user something true
+ * instead of a generic "couldn't read the shared file". */
+ sealed class Outcome {
+ data class Ok(val file: ClipboardOutgoingFile) : Outcome()
+ /** Unreadable or empty. There is no longer a "too large". */
+ data class Unreadable(val why: String) : Outcome()
}
- private fun readInner(context: Context, uri: Uri): ClipboardOutgoingFile? {
+ /**
+ * Describe [uri] without reading it, or explain why it cannot be sent.
+ *
+ * The only I/O here is opening the stream briefly to prove it is readable.
+ * Discovering at pull time that a file was never readable would mean the
+ * user sees a share succeed and a transfer fail minutes later, so the cheap
+ * check is worth one open.
+ */
+ /** The file, or null if it could not be read or was over the cap.
+ *
+ * For callers with nowhere to put the reason — a MediaStore auto-send, a
+ * file-browser fetch. Anything facing the user should call [read] and say
+ * which of the two it was: "too large" and "unreadable" are different
+ * problems and only one of them is the user's to fix. */
+ fun readOrNull(context: Context, uri: Uri): ClipboardOutgoingFile? =
+ (read(context, uri) as? Outcome.Ok)?.file
+
+ fun read(context: Context, uri: Uri): Outcome {
val cr = context.contentResolver
val mime = cr.getType(uri) ?: "application/octet-stream"
val name = displayName(context, uri) ?: "file"
- // Ask how big it is BEFORE reading it. `readBytes()` pulls the whole
- // file into the service's heap, so checking the cap afterwards means
- // the one thing the cap exists to prevent — a file far too large to
- // hold — has already happened. Harmless while this only ever saw
- // screenshots; a phone video is hundreds of megabytes.
- val declared = declaredSize(context, uri)
- if (declared != null && declared > MAX_FILE_BYTES) {
- Log.i(TAG, "file too large ($declared bytes) — not sent")
- return null
- }
- val bytes = cr.openInputStream(uri)?.use { it.readBytes() }
- return when {
- bytes == null -> null
- bytes.isEmpty() -> null
- // Backstop: SIZE is provider-supplied and may be absent or wrong.
- bytes.size > MAX_FILE_BYTES -> {
- Log.i(TAG, "file too large (${bytes.size} bytes) — not sent")
- null
- }
- else -> ClipboardOutgoingFile(bytes, name, mime)
+ val size = reportedSize(context, uri)
+
+ return try {
+ val readable = cr.openFileDescriptor(uri, "r")?.use { pfd ->
+ // A zero-length file is not worth a transfer, and an empty
+ // provider read is the usual symptom of a URI we cannot really
+ // open. `statSize` is -1 when the provider will not say, which
+ // is not itself a failure.
+ val st = try { pfd.statSize } catch (_: Exception) { -1L }
+ st != 0L
+ } ?: return Outcome.Unreadable("no file descriptor")
+ if (!readable) return Outcome.Unreadable("empty file")
+ Outcome.Ok(ClipboardOutgoingFile(uri, name, mime, size))
+ } catch (e: Exception) {
+ Log.w(TAG, "file not readable: ${e.message}")
+ Outcome.Unreadable(e.message ?: "read failed")
}
}
- /** The provider's own SIZE for [uri], or null when it doesn't report one. */
- private fun declaredSize(context: Context, uri: Uri): Long? = try {
+ /** `OpenableColumns.SIZE`, or -1 when the provider does not report one. */
+ private fun reportedSize(context: Context, uri: Uri): Long = try {
context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)
?.use { c ->
val idx = c.getColumnIndex(OpenableColumns.SIZE)
- if (c.moveToFirst() && idx >= 0 && !c.isNull(idx)) c.getLong(idx) else null
- }
+ if (c.moveToFirst() && idx >= 0 && !c.isNull(idx)) c.getLong(idx) else -1L
+ } ?: -1L
} catch (_: Exception) {
- null
+ -1L
}
private fun displayName(context: Context, uri: Uri): String? = try {
diff --git a/android/app/src/main/java/com/vortex/a3/core/clipboard/ShareReceiverActivity.kt b/android/app/src/main/java/com/vortex/a3/core/clipboard/ShareReceiverActivity.kt
index d93de29..7d1b3a6 100644
--- a/android/app/src/main/java/com/vortex/a3/core/clipboard/ShareReceiverActivity.kt
+++ b/android/app/src/main/java/com/vortex/a3/core/clipboard/ShareReceiverActivity.kt
@@ -38,7 +38,16 @@ class ShareReceiverActivity : Activity() {
val title = intent.getStringExtra(Intent.EXTRA_SUBJECT)
?.takeIf { it.isNotBlank() } ?: ""
VortexService.handoffBus.tryEmit(
- com.vortex.a3.core.handoff.HandoffEvent(url = url, title = title, openNow = true),
+ com.vortex.a3.core.handoff.HandoffEvent(
+ url = url,
+ title = title,
+ openNow = true,
+ // Identifies THIS share so the laptop opens it once, no
+ // matter how many transports and heartbeats deliver it.
+ // A fresh id per share is what keeps re-sharing the same
+ // page working (the laptop dedups on the id, not the URL).
+ id = java.util.UUID.randomUUID().toString(),
+ ),
)
Log.i(TAG, "share: forwarded a page to the laptop")
Toast.makeText(this, "Opening on laptop…", Toast.LENGTH_SHORT).show()
@@ -76,28 +85,52 @@ class ShareReceiverActivity : Activity() {
}
}
- var sent = 0
- for (uri in uris) {
- val file = ClipboardFileReader.read(this, uri)
- if (file != null) {
- // `tryEmit` returning false means the buffer was full and this
- // file went nowhere — which used to happen silently, and then be
- // counted as sent. Count only what the bus actually took, so the
- // toast tells the truth.
- if (VortexService.clipboardFileBus.tryEmit(file)) {
- Log.i(TAG, "share: forwarded file '${file.name}' (${file.bytes.size} bytes)")
- sent++
- } else {
- Log.w(TAG, "share: bus full, dropped '${file.name}'")
- }
+ // Hand the whole list to the service and let it pace itself.
+ //
+ // Deliberately NOT read here: reading every file up front is what made
+ // an 835 MB share an OutOfMemoryError, and what made a 150-file share
+ // lose most of its files to buffer overflow. The service reads each
+ // file on its turn (see ShareQueue), so memory is flat and the batch is
+ // bounded by real delivery instead of a cap that refuses work.
+ //
+ // ClipData + FLAG_GRANT_READ_URI_PERMISSION is what carries the share
+ // sheet's read grant across to the service; plain extras would not.
+ if (uris.isEmpty()) {
+ // Nothing readable in the share and the text path above did not
+ // claim it. `uris.first()` below would throw.
+ Log.w(TAG, "share: no URIs and no text — nothing to do")
+ Toast.makeText(this, "Nothing to send", Toast.LENGTH_SHORT).show()
+ finish()
+ overridePendingTransition(0, 0)
+ return
+ }
+ val clip = android.content.ClipData.newUri(contentResolver, "vortex-share", uris.first())
+ for (u in uris.drop(1)) clip.addItem(android.content.ClipData.Item(u))
+ val svc = android.content.Intent(this, VortexService::class.java).apply {
+ action = VortexService.ACTION_ENQUEUE_SHARE
+ clipData = clip
+ addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ try {
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
+ startForegroundService(svc)
} else {
- Log.w(TAG, "share: couldn't read $uri")
+ startService(svc)
}
+ } catch (e: Exception) {
+ Log.w(TAG, "couldn't hand the share to the service: ${e.message}")
+ Toast.makeText(this, "Couldn't start the transfer", Toast.LENGTH_SHORT).show()
+ finish()
+ overridePendingTransition(0, 0)
+ return
}
- val msg = when {
- sent == 0 -> "Couldn't read the shared file(s)"
- sent == 1 -> "Sending file to laptop…"
- else -> "Sending $sent files to laptop…"
+ Log.i(TAG, "share: handed ${uris.size} file(s) to the queue")
+ // One toast. Per-file progress is the notification the queue maintains —
+ // a toast per file meant 150 toasts for a 150-file share.
+ val msg = if (uris.size == 1) {
+ "Sending file to laptop…"
+ } else {
+ "Queued ${uris.size} files for the laptop"
}
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
diff --git a/android/app/src/main/java/com/vortex/a3/core/files/PhoneFiles.kt b/android/app/src/main/java/com/vortex/a3/core/files/PhoneFiles.kt
index 2eff73a..ac10a49 100644
--- a/android/app/src/main/java/com/vortex/a3/core/files/PhoneFiles.kt
+++ b/android/app/src/main/java/com/vortex/a3/core/files/PhoneFiles.kt
@@ -185,7 +185,7 @@ object PhoneFiles {
*/
fun read(context: Context, raw: String): com.vortex.a3.core.clipboard.ClipboardOutgoingFile? {
val uri = resolve(context, raw) ?: return null
- return com.vortex.a3.core.clipboard.ClipboardFileReader.read(context, uri)
+ return com.vortex.a3.core.clipboard.ClipboardFileReader.readOrNull(context, uri)
}
/** `primary:Download` reads better as `Download`. */
diff --git a/android/app/src/main/java/com/vortex/a3/core/fs/FsClient.kt b/android/app/src/main/java/com/vortex/a3/core/fs/FsClient.kt
new file mode 100644
index 0000000..99ae48b
--- /dev/null
+++ b/android/app/src/main/java/com/vortex/a3/core/fs/FsClient.kt
@@ -0,0 +1,292 @@
+package com.vortex.a3.core.fs
+
+import android.util.Log
+import com.vortex.a3.core.ble.FrameType
+import java.io.File
+import java.io.RandomAccessFile
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.withTimeoutOrNull
+import org.json.JSONObject
+
+/**
+ * The phone's end of the ranged-filesystem protocol as a CONSUMER: browsing the
+ * laptop's shared folders and pulling files off it.
+ *
+ * The mirror image of [FsServer], and the counterpart of Rust `fs_link`'s
+ * client half. The protocol is symmetric, so this needs no new frames — it
+ * sends the same FS_REQ ops the laptop sends us and reads the same replies.
+ *
+ * Requests are pipelined and correlated by id: a browse issues a listing while
+ * a download is still running, and replies may arrive in any order.
+ *
+ * Transport is BLE. The laptop prefers Wi-Fi for the same traffic in the other
+ * direction, but it can do that because the phone LISTENS on TCP and the laptop
+ * dials it; there is no listener the other way, so a phone-initiated LAN
+ * session has nothing to connect to. Listings are small and fine over BLE;
+ * pulling a large file this way is slow, and closing that gap means giving the
+ * laptop a listener.
+ */
+object FsClient {
+
+ /** Failure of one request, errno-shaped so callers can say something true.
+ * [FsCode] values, or [TIMEOUT] when the laptop never answered. */
+ class FsException(val code: Int, message: String) : Exception(message)
+
+ /** No reply inside [REQUEST_TIMEOUT_MS]. Distinct from any server code so
+ * "the laptop went away" never reads as "the file is missing". */
+ const val TIMEOUT = -1
+
+ /**
+ * How long a request waits. Generous because the far side may be reading a
+ * cold disk, but finite: a UI blocked forever on a laptop that went to
+ * sleep is this feature's worst outcome.
+ */
+ private const val REQUEST_TIMEOUT_MS = 20_000L
+
+ /**
+ * Ranged reads kept in flight at once.
+ *
+ * The gain is hiding the round trip, not the disk: the laptop serves reads
+ * under one lock, so they do not overlap there, but each request otherwise
+ * waited a full RTT before the next was even sent. Four is deliberately
+ * modest — it covers the latency without committing much: the replies are
+ * 48 KiB each, and on a BLE fallback every one of those is ~96 paced
+ * notify fragments, so a large window would flood a link that cannot
+ * absorb it.
+ */
+ private const val READ_WINDOW = 4
+
+ private const val TAG = "VortexFs"
+
+ /** Set by VortexStack: sends one FS_REQ, returns false if there is no link. */
+ @Volatile
+ var sender: ((op: Byte, payload: ByteArray) -> Boolean)? = null
+
+ private val lock = Any()
+ private var nextId = 1
+ private val inflight = HashMap>()
+
+ private sealed class Reply {
+ data class Meta(val json: JSONObject) : Reply()
+ data class Data(val d: FsData) : Reply()
+ data class Err(val e: FsErr) : Reply()
+ }
+
+ /** Feed a reply frame in. Wired to `GattServer.onFsReply`. */
+ fun onReply(frameType: Byte, payload: ByteArray) {
+ val reply: Reply = when (frameType) {
+ FrameType.FS_DATA -> Reply.Data(decodeData(payload) ?: run {
+ Log.w(TAG, "fs: truncated FS_DATA")
+ return
+ })
+ FrameType.FS_ERR -> {
+ val o = runCatching { JSONObject(String(payload, Charsets.UTF_8)) }.getOrNull()
+ ?: return
+ Reply.Err(FsErr.from(o))
+ }
+ FrameType.FS_META -> {
+ val o = runCatching { JSONObject(String(payload, Charsets.UTF_8)) }.getOrNull()
+ ?: return
+ Reply.Meta(o)
+ }
+ else -> return
+ }
+ val id = when (reply) {
+ is Reply.Data -> reply.d.id
+ is Reply.Err -> reply.e.id
+ is Reply.Meta -> reply.json.optInt("id")
+ }
+ val waiter = synchronized(lock) { inflight.remove(id) }
+ if (waiter == null) {
+ // A reply to a request that already timed out, or an id we never
+ // issued. Dropped, but logged: silently ignoring these hides a
+ // desynchronised protocol.
+ Log.i(TAG, "fs: reply for unknown id=$id")
+ return
+ }
+ waiter.complete(reply)
+ }
+
+ /** Drop every waiter — the link went, so nothing in flight can be answered. */
+ fun reset() {
+ val waiters = synchronized(lock) {
+ val all = inflight.values.toList()
+ inflight.clear()
+ all
+ }
+ // Fail them rather than leaving callers parked on the timeout: the
+ // answer is already known.
+ waiters.forEach { it.complete(Reply.Err(FsErr(0, FsCode.IO, "link went away"))) }
+ }
+
+ private suspend fun roundTrip(op: Byte, id: Int, payload: ByteArray): Reply {
+ val d = CompletableDeferred()
+ synchronized(lock) { inflight[id] = d }
+ try {
+ val send = sender ?: throw FsException(FsCode.IO, "no link to the laptop")
+ if (!send(op, payload)) throw FsException(FsCode.IO, "no link to the laptop")
+ val reply = withTimeoutOrNull(REQUEST_TIMEOUT_MS) { d.await() }
+ ?: throw FsException(TIMEOUT, "the laptop did not answer")
+ if (reply is Reply.Err) throw FsException(reply.e.code, reply.e.msg)
+ return reply
+ } finally {
+ // Always, including on CANCELLATION. Pipelining made that matter:
+ // when one read of a batch fails the rest are cancelled, and each
+ // used to leave its id in the map for the life of the process.
+ synchronized(lock) { inflight.remove(id) }
+ }
+ }
+
+ /** One ranged read, as a suspending call the download loop can overlap. */
+ private suspend fun readAt(handle: Long, offset: Long, len: Int): FsData {
+ val id = newId()
+ val r = roundTrip(
+ FsOp.READ,
+ id,
+ ReadReq(id, handle, offset, len).toJson().toString().toByteArray(),
+ )
+ return (r as? Reply.Data)?.d ?: throw FsException(FsCode.IO, "expected data")
+ }
+
+ private fun newId(): Int = synchronized(lock) {
+ // Wrapping is fine: ids only need to be unique among what is in flight,
+ // and 0 is reserved for "no particular request" in FS_ERR.
+ nextId += 1
+ if (nextId <= 0) nextId = 1
+ nextId
+ }
+
+ /** One page of a directory. Empty path is the laptop's synthetic root. */
+ suspend fun list(path: String, cursor: Int = 0): Pair, Int?> {
+ val id = newId()
+ val r = roundTrip(FsOp.LIST, id, ListReq(id, path, cursor).toJson().toString().toByteArray())
+ val o = (r as? Reply.Meta)?.json ?: throw FsException(FsCode.IO, "unexpected reply")
+ val arr = o.optJSONArray("entries")
+ val out = ArrayList(arr?.length() ?: 0)
+ for (i in 0 until (arr?.length() ?: 0)) out.add(FsEntry.from(arr!!.getJSONObject(i)))
+ val next = if (o.has("cursor") && !o.isNull("cursor")) o.optInt("cursor") else null
+ return out to next
+ }
+
+ /** Every page of a directory, followed to the end. */
+ suspend fun listAll(path: String): List {
+ val out = ArrayList()
+ var cursor: Int? = 0
+ var pages = 0
+ while (cursor != null) {
+ val (page, next) = list(path, cursor)
+ out.addAll(page)
+ cursor = next
+ // A peer that keeps handing back a cursor without advancing would
+ // loop us forever; stop rather than spin.
+ if (++pages > 1000) break
+ }
+ return out
+ }
+
+ /**
+ * Download [path] to [dest], streaming in ranges.
+ *
+ * Peak memory is one chunk however big the file is — the same property that
+ * removed the transfer size cap in the other direction. [onProgress] gets
+ * bytes-so-far and the total (or -1 when unknown).
+ */
+ suspend fun download(
+ path: String,
+ dest: File,
+ onProgress: (done: Long, total: Long) -> Unit = { _, _ -> },
+ ): File {
+ val openId = newId()
+ val opened = roundTrip(
+ FsOp.OPEN,
+ openId,
+ OpenReq(openId, path).toJson().toString().toByteArray(),
+ )
+ val o = (opened as? Reply.Meta)?.json ?: throw FsException(FsCode.IO, "unexpected reply")
+ val handle = o.optLong("handle")
+ val size = o.optLong("size", -1)
+
+ dest.parentFile?.mkdirs()
+ var offset = 0L
+ try {
+ RandomAccessFile(dest, "rw").use { out ->
+ out.setLength(0)
+ if (size > 0) {
+ // Pipelined: keep [READ_WINDOW] reads outstanding so the
+ // next request is already on the wire while the current
+ // reply is still coming back. Only when the size is known —
+ // without it there is no way to tell how many reads to
+ // issue, and speculative ones past the end would be waste
+ // on a link this feature exists to stop wasting.
+ coroutineScope {
+ val inflight = ArrayDeque>()
+ var nextOffset = 0L
+ while (offset < size) {
+ while (inflight.size < READ_WINDOW && nextOffset < size) {
+ val at = nextOffset
+ nextOffset += MAX_READ_LEN
+ inflight.addLast(async { readAt(handle, at, MAX_READ_LEN) })
+ }
+ // Consumed in ISSUE order, which is also offset
+ // order, so the file is written front to back and
+ // progress only ever moves forward. Replies may
+ // still arrive in any order; this just declines to
+ // care.
+ val d = inflight.removeFirst().await()
+ if (d.bytes.isNotEmpty()) {
+ out.seek(d.offset)
+ out.write(d.bytes)
+ offset = d.offset + d.bytes.size
+ onProgress(offset, size)
+ } else if (!d.eof) {
+ throw FsException(FsCode.IO, "transfer stalled")
+ }
+ if (d.eof && inflight.isEmpty()) break
+ }
+ // A short file, or one that shrank under us: drop the
+ // rest rather than awaiting reads past its end.
+ inflight.forEach { it.cancel() }
+ }
+ } else {
+ // Unknown size: sequential, following EOF.
+ while (true) {
+ val d = readAt(handle, offset, MAX_READ_LEN)
+ if (d.bytes.isNotEmpty()) {
+ out.seek(d.offset)
+ out.write(d.bytes)
+ offset = d.offset + d.bytes.size
+ onProgress(offset, size)
+ }
+ if (d.eof) break
+ if (d.bytes.isEmpty()) {
+ // No EOF and no bytes: the far side is not
+ // advancing, and retrying would spin forever.
+ throw FsException(FsCode.IO, "transfer stalled")
+ }
+ }
+ }
+ }
+ } catch (e: Exception) {
+ // No half-written file left behind: a truncated download looks like
+ // a real one and is worse than none.
+ dest.delete()
+ closeQuietly(handle)
+ throw e
+ }
+ closeQuietly(handle)
+ return dest
+ }
+
+ private suspend fun closeQuietly(handle: Long) {
+ try {
+ val id = newId()
+ roundTrip(FsOp.CLOSE, id, CloseReq(id, handle).toJson().toString().toByteArray())
+ } catch (e: Exception) {
+ // The far side expires idle handles anyway; failing to close is not
+ // worth failing a completed download over.
+ Log.i(TAG, "fs: close failed harmlessly: ${e.message}")
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/vortex/a3/core/fs/FsHandles.kt b/android/app/src/main/java/com/vortex/a3/core/fs/FsHandles.kt
new file mode 100644
index 0000000..9f42009
--- /dev/null
+++ b/android/app/src/main/java/com/vortex/a3/core/fs/FsHandles.kt
@@ -0,0 +1,116 @@
+package com.vortex.a3.core.fs
+
+import android.os.ParcelFileDescriptor
+import android.util.Log
+
+/**
+ * Open read handles for one peer. Mirrors Rust `fs_server::FsHandles`, and for
+ * the same reasons.
+ *
+ * Per-peer rather than global: with several paired laptops, one peer's handle
+ * ids must not address another's open files. Multi-peer made "whose statement
+ * is this" load-bearing throughout the codebase and a handle table is no
+ * different.
+ */
+class FsHandles {
+
+ private class Handle(
+ val pfd: ParcelFileDescriptor,
+ val size: Long,
+ var lastUsed: Long,
+ /** Set when this handle is reading a share-sheet file: its CLOSE is
+ * what tells the sender the laptop actually has the bytes. */
+ val shareToken: String?,
+ )
+
+ private val lock = Any()
+ private var next: Long = 0
+ private val open = HashMap()
+
+ /**
+ * Register an open descriptor, returning its handle id, or `null` when we
+ * are already holding as many as we will.
+ *
+ * A peer that opens in a loop and never closes must not be able to exhaust
+ * the process's descriptors, so the table is bounded and idle entries are
+ * pruned first.
+ */
+ fun insert(pfd: ParcelFileDescriptor, size: Long, shareToken: String? = null): Long? = synchronized(lock) {
+ prune()
+ if (open.size >= MAX_HANDLES) {
+ Log.w(TAG, "fs: handle table full ($MAX_HANDLES); refusing OPEN")
+ return null
+ }
+ // Start at 1: 0 is never valid, because it is the value a buggy
+ // consumer is most likely to send by accident.
+ next += 1
+ if (next <= 0) next = 1
+ val id = next
+ open[id] = Handle(pfd, size, System.nanoTime(), shareToken)
+ id
+ }
+
+ /** Look a handle up and mark it fresh, or `null` if unknown/expired. */
+ fun get(id: Long): Pair? = synchronized(lock) {
+ prune()
+ val h = open[id] ?: return null
+ h.lastUsed = System.nanoTime()
+ Pair(h.pfd, h.size)
+ }
+
+ /**
+ * Close and forget a handle, returning its share token if it had one.
+ *
+ * Only an EXPLICIT close reports a token — [prune] deliberately does not.
+ * An expired handle means the reader went away mid-file, which is the
+ * opposite of delivery, and counting it would tell the user a transfer
+ * succeeded when it was abandoned.
+ */
+ fun remove(id: Long): String? = synchronized(lock) {
+ val h = open.remove(id) ?: return null
+ close(h)
+ h.shareToken
+ }
+
+ /** Close everything. Called when the link drops: handles cannot outlive
+ * the session that owns them. */
+ fun clear() = synchronized(lock) {
+ open.values.forEach { close(it) }
+ open.clear()
+ }
+
+ fun size(): Int = synchronized(lock) { open.size }
+
+ /**
+ * Drop handles nothing has touched for [IDLE_TIMEOUT_NS].
+ *
+ * A consumer that dies mid-copy — a file manager killed, a mount
+ * unmounted — never sends CLOSE, and one leaked descriptor per abandoned
+ * read would eventually exhaust us. Reopening is cheap; leaking is not.
+ */
+ private fun prune() {
+ val now = System.nanoTime()
+ val dead = open.filterValues { now - it.lastUsed > IDLE_TIMEOUT_NS }
+ if (dead.isEmpty()) return
+ dead.forEach { (id, h) ->
+ Log.i(TAG, "fs: expiring idle handle $id")
+ close(h)
+ open.remove(id)
+ }
+ }
+
+ private fun close(h: Handle) {
+ try {
+ h.pfd.close()
+ } catch (_: Exception) {
+ // Closing twice, or after the provider died, is not worth a log
+ // line — the descriptor is gone either way.
+ }
+ }
+
+ companion object {
+ private const val TAG = "VortexFs"
+ private const val MAX_HANDLES = 64
+ private const val IDLE_TIMEOUT_NS = 300_000_000_000L // 300 s
+ }
+}
diff --git a/android/app/src/main/java/com/vortex/a3/core/fs/FsProto.kt b/android/app/src/main/java/com/vortex/a3/core/fs/FsProto.kt
new file mode 100644
index 0000000..7ec5463
--- /dev/null
+++ b/android/app/src/main/java/com/vortex/a3/core/fs/FsProto.kt
@@ -0,0 +1,373 @@
+package com.vortex.a3.core.fs
+
+import org.json.JSONArray
+import org.json.JSONObject
+
+/**
+ * Ranged-filesystem protocol — the Kotlin mirror of Rust
+ * `core::fs_proto` (see `docs/design/file-browsing.md`).
+ *
+ * One primitive sits underneath both file browsing and large-file transfer:
+ *
+ * ```
+ * READ(handle, offset, len) -> bytes
+ * ```
+ *
+ * Every transfer today buffers a whole file in memory (the old blob store
+ * holds bytes keyed by a content token), which is what made an 835 MB share an
+ * `OutOfMemoryError` and why a 64 MB cap exists. Ranged reads remove the cap as
+ * a side effect rather than as a separate change.
+ *
+ * The protocol is **symmetric**: this phone serves these ops so the laptop can
+ * browse its storage, and sends them so it can browse the laptop's. Nothing
+ * here names a side.
+ *
+ * Wire types must stay byte-identical to the Rust module — the field names are
+ * the contract.
+ */
+
+/** Op codes, carried in the frame's `sub` byte. Mirrors Rust `fs_proto::op`. */
+object FsOp {
+ const val LIST: Byte = 0x01
+ const val STAT: Byte = 0x02
+ const val OPEN: Byte = 0x03
+ const val READ: Byte = 0x04
+ const val WRITE: Byte = 0x05
+ const val CLOSE: Byte = 0x06
+ const val SETMETA: Byte = 0x07
+}
+
+/**
+ * Error codes. Errno-shaped on purpose: the laptop's mount adapters (FUSE,
+ * ProjFS) turn these straight back into OS errors, and a private vocabulary
+ * would mean two lossy translations instead of none.
+ *
+ * Mirrors Rust `fs_proto::code`.
+ */
+object FsCode {
+ const val NOENT = 2
+ const val ACCES = 13
+ const val IO = 5
+ const val BADF = 9
+ const val INVAL = 22
+
+ /**
+ * Defined and wired, deliberately not implemented. Answered explicitly,
+ * never dropped: a stub that looks like a timeout is worse than an honest
+ * refusal.
+ */
+ const val NOTSUP = 95
+ const val ISDIR = 21
+ const val ROFS = 30
+}
+
+/**
+ * Bytes per READ. Bounded so memory stays flat on both sides regardless of file
+ * size — the consumer issues many ranged reads rather than one huge one, which
+ * is the entire point.
+ */
+const val MAX_READ_LEN = 48 * 1024
+
+/** Entries per LIST page. A 10,000-entry folder must not be one frame. */
+const val LIST_PAGE = 256
+
+/** Binary header on an FS_DATA payload: id(4) + offset(8) + flags(1). */
+const val DATA_HEADER_LEN = 13
+
+/** FS_DATA flag: this reply reaches end-of-file. */
+const val FLAG_EOF: Int = 0x01
+
+// ---------------------------------------------------------------------------
+// Requests — parsed from JSON when serving, built when consuming
+// ---------------------------------------------------------------------------
+
+data class ListReq(val id: Int, val path: String, val cursor: Int = 0) {
+ fun toJson(): JSONObject =
+ JSONObject().put("id", id).put("path", path).put("cursor", cursor)
+
+ companion object {
+ fun from(o: JSONObject) =
+ ListReq(o.optInt("id"), o.optString("path"), o.optInt("cursor", 0))
+ }
+}
+
+data class StatReq(val id: Int, val path: String) {
+ fun toJson(): JSONObject = JSONObject().put("id", id).put("path", path)
+
+ companion object {
+ fun from(o: JSONObject) = StatReq(o.optInt("id"), o.optString("path"))
+ }
+}
+
+data class OpenReq(val id: Int, val path: String, val write: Boolean = false) {
+ fun toJson(): JSONObject =
+ JSONObject().put("id", id).put("path", path).put("write", write)
+
+ companion object {
+ fun from(o: JSONObject) =
+ OpenReq(o.optInt("id"), o.optString("path"), o.optBoolean("write", false))
+ }
+}
+
+/**
+ * Read [len] bytes at [offset]. A short reply is normal — end of file, or the
+ * server chose a smaller slice — and is not necessarily EOF; check [FLAG_EOF].
+ */
+data class ReadReq(val id: Int, val handle: Long, val offset: Long, val len: Int) {
+ fun toJson(): JSONObject = JSONObject()
+ .put("id", id).put("handle", handle).put("offset", offset).put("len", len)
+
+ companion object {
+ fun from(o: JSONObject) = ReadReq(
+ o.optInt("id"), o.optLong("handle"), o.optLong("offset"), o.optInt("len"),
+ )
+ }
+}
+
+/** Write at [offset]; the bytes ride a binary tail (see [encodeWrite]). */
+data class WriteReq(val id: Int, val handle: Long, val offset: Long) {
+ fun toJson(): JSONObject =
+ JSONObject().put("id", id).put("handle", handle).put("offset", offset)
+
+ companion object {
+ fun from(o: JSONObject) =
+ WriteReq(o.optInt("id"), o.optLong("handle"), o.optLong("offset"))
+ }
+}
+
+data class CloseReq(val id: Int, val handle: Long) {
+ fun toJson(): JSONObject = JSONObject().put("id", id).put("handle", handle)
+
+ companion object {
+ fun from(o: JSONObject) = CloseReq(o.optInt("id"), o.optLong("handle"))
+ }
+}
+
+data class SetMetaReq(
+ val id: Int,
+ val path: String,
+ val mtime: Long? = null,
+ /** A bare NAME within the same directory, never a path. */
+ val renameTo: String? = null,
+) {
+ fun toJson(): JSONObject = JSONObject().put("id", id).put("path", path).also {
+ if (mtime != null) it.put("mtime", mtime)
+ if (renameTo != null) it.put("rename_to", renameTo)
+ }
+
+ companion object {
+ fun from(o: JSONObject) = SetMetaReq(
+ o.optInt("id"),
+ o.optString("path"),
+ if (o.has("mtime") && !o.isNull("mtime")) o.optLong("mtime") else null,
+ if (o.has("rename_to") && !o.isNull("rename_to")) o.optString("rename_to") else null,
+ )
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Replies
+// ---------------------------------------------------------------------------
+
+/**
+ * One directory entry, or a stat result.
+ *
+ * Deliberately minimal: a file manager needs name, kind, size and mtime to draw
+ * a row, and every extra field is bytes on a link that may be BLE.
+ *
+ * [path] is an opaque, server-defined addressing token — a SAF document URI
+ * here, an absolute path on the laptop. The far side must send it back verbatim
+ * and must never build a child address by joining [name] onto its parent:
+ * under SAF a name is simply not addressable.
+ */
+data class FsEntry(
+ val name: String,
+ val isDir: Boolean = false,
+ val size: Long = 0,
+ val mtime: Long = 0,
+ val readonly: Boolean = false,
+ val path: String = "",
+) {
+ fun toJson(): JSONObject = JSONObject()
+ .put("name", name)
+ .put("is_dir", isDir)
+ .put("size", size)
+ .put("mtime", mtime)
+ .put("readonly", readonly)
+ .also { if (path.isNotEmpty()) it.put("path", path) }
+
+ companion object {
+ fun from(o: JSONObject) = FsEntry(
+ o.optString("name"),
+ o.optBoolean("is_dir", false),
+ o.optLong("size", 0),
+ o.optLong("mtime", 0),
+ o.optBoolean("readonly", false),
+ o.optString("path", ""),
+ )
+ }
+}
+
+/**
+ * A successful non-data reply, carried as JSON in an FS_META frame. Serialised
+ * with a `kind` tag to match Rust's `#[serde(tag = "kind")]`.
+ */
+sealed class FsReply {
+ abstract val id: Int
+
+ data class ListPage(
+ override val id: Int,
+ val entries: List,
+ /** Resume point for the next page, or null when complete. Non-null
+ * always means "call again" — never a guess. */
+ val cursor: Int? = null,
+ ) : FsReply()
+
+ data class Stat(override val id: Int, val entry: FsEntry) : FsReply()
+
+ data class Open(
+ override val id: Int,
+ val handle: Long,
+ /** Size at open time, so the consumer can plan reads without a
+ * follow-up stat. */
+ val size: Long,
+ val readonly: Boolean = false,
+ ) : FsReply()
+
+ data class Wrote(override val id: Int, val bytes: Int) : FsReply()
+
+ /** Generic success for ops with nothing to report (CLOSE, SETMETA). */
+ data class Ok(override val id: Int) : FsReply()
+
+ fun toJsonBytes(): ByteArray = toJson().toString().toByteArray(Charsets.UTF_8)
+
+ fun toJson(): JSONObject = when (this) {
+ is ListPage -> JSONObject()
+ .put("kind", "list")
+ .put("id", id)
+ .put("entries", JSONArray().also { a -> entries.forEach { a.put(it.toJson()) } })
+ .also { if (cursor != null) it.put("cursor", cursor) }
+ is Stat -> JSONObject().put("kind", "stat").put("id", id).put("entry", entry.toJson())
+ is Open -> JSONObject().put("kind", "open").put("id", id)
+ .put("handle", handle).put("size", size).put("readonly", readonly)
+ is Wrote -> JSONObject().put("kind", "wrote").put("id", id).put("bytes", bytes)
+ is Ok -> JSONObject().put("kind", "ok").put("id", id)
+ }
+
+ companion object {
+ fun from(o: JSONObject): FsReply? {
+ val id = o.optInt("id")
+ return when (o.optString("kind")) {
+ "list" -> {
+ val arr = o.optJSONArray("entries") ?: JSONArray()
+ val out = ArrayList(arr.length())
+ for (i in 0 until arr.length()) {
+ arr.optJSONObject(i)?.let { out.add(FsEntry.from(it)) }
+ }
+ ListPage(
+ id,
+ out,
+ if (o.has("cursor") && !o.isNull("cursor")) o.optInt("cursor") else null,
+ )
+ }
+ "stat" -> o.optJSONObject("entry")?.let { Stat(id, FsEntry.from(it)) }
+ "open" -> Open(
+ id, o.optLong("handle"), o.optLong("size"), o.optBoolean("readonly", false),
+ )
+ "wrote" -> Wrote(id, o.optInt("bytes"))
+ "ok" -> Ok(id)
+ else -> null
+ }
+ }
+ }
+}
+
+/**
+ * A definite failure. Every failing op sends one — silence is never an answer,
+ * because the far side cannot tell it from a lost frame.
+ */
+data class FsErr(val id: Int, val code: Int, val msg: String = "") {
+ fun toJsonBytes(): ByteArray = JSONObject()
+ .put("id", id).put("code", code).put("msg", msg)
+ .toString().toByteArray(Charsets.UTF_8)
+
+ companion object {
+ fun from(o: JSONObject) =
+ FsErr(o.optInt("id"), o.optInt("code"), o.optString("msg", ""))
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Binary framings
+// ---------------------------------------------------------------------------
+
+/** Build an FS_DATA payload: `[id u32 BE][offset u64 BE][flags u8][bytes]`. */
+fun encodeData(id: Int, offset: Long, eof: Boolean, bytes: ByteArray, count: Int = bytes.size): ByteArray {
+ val out = ByteArray(DATA_HEADER_LEN + count)
+ out[0] = (id ushr 24).toByte()
+ out[1] = (id ushr 16).toByte()
+ out[2] = (id ushr 8).toByte()
+ out[3] = id.toByte()
+ for (i in 0 until 8) out[4 + i] = (offset ushr (56 - 8 * i)).toByte()
+ out[12] = if (eof) FLAG_EOF.toByte() else 0
+ System.arraycopy(bytes, 0, out, DATA_HEADER_LEN, count)
+ return out
+}
+
+/** Parsed FS_DATA payload. [bytes] is a copy, safe to retain. */
+data class FsData(val id: Int, val offset: Long, val eof: Boolean, val bytes: ByteArray) {
+ // ByteArray in a data class: identity equals/hashCode would be wrong and
+ // silently break any set/map use, so both are content-based.
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is FsData) return false
+ return id == other.id && offset == other.offset && eof == other.eof &&
+ bytes.contentEquals(other.bytes)
+ }
+
+ override fun hashCode(): Int {
+ var h = id
+ h = 31 * h + offset.hashCode()
+ h = 31 * h + eof.hashCode()
+ h = 31 * h + bytes.contentHashCode()
+ return h
+ }
+}
+
+/** Parse an FS_DATA payload, or null if truncated. */
+fun decodeData(p: ByteArray): FsData? {
+ if (p.size < DATA_HEADER_LEN) return null
+ var id = 0
+ for (i in 0 until 4) id = (id shl 8) or (p[i].toInt() and 0xFF)
+ var off = 0L
+ for (i in 0 until 8) off = (off shl 8) or (p[4 + i].toLong() and 0xFF)
+ val eof = (p[12].toInt() and FLAG_EOF) != 0
+ return FsData(id, off, eof, p.copyOfRange(DATA_HEADER_LEN, p.size))
+}
+
+/** Build an FS_REQ/WRITE payload: `[json_len u16 BE][json][bytes]`. */
+fun encodeWrite(req: WriteReq, bytes: ByteArray, count: Int = bytes.size): ByteArray {
+ val json = req.toJson().toString().toByteArray(Charsets.UTF_8)
+ val out = ByteArray(2 + json.size + count)
+ out[0] = (json.size ushr 8).toByte()
+ out[1] = json.size.toByte()
+ System.arraycopy(json, 0, out, 2, json.size)
+ System.arraycopy(bytes, 0, out, 2 + json.size, count)
+ return out
+}
+
+/** Parse an FS_REQ/WRITE payload, or null if truncated / malformed. */
+fun decodeWrite(p: ByteArray): Pair? {
+ if (p.size < 2) return null
+ val n = ((p[0].toInt() and 0xFF) shl 8) or (p[1].toInt() and 0xFF)
+ val end = 2 + n
+ // A peer can claim any length; a lying header must decode to null rather
+ // than throw out of the frame handler.
+ if (end > p.size) return null
+ val req = try {
+ WriteReq.from(JSONObject(String(p, 2, n, Charsets.UTF_8)))
+ } catch (_: Exception) {
+ return null
+ }
+ return req to p.copyOfRange(end, p.size)
+}
diff --git a/android/app/src/main/java/com/vortex/a3/core/fs/FsRoots.kt b/android/app/src/main/java/com/vortex/a3/core/fs/FsRoots.kt
new file mode 100644
index 0000000..60fe8b4
--- /dev/null
+++ b/android/app/src/main/java/com/vortex/a3/core/fs/FsRoots.kt
@@ -0,0 +1,318 @@
+package com.vortex.a3.core.fs
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Environment
+import android.provider.DocumentsContract
+import android.util.Log
+import java.io.File
+
+/**
+ * What this phone serves to a paired laptop, and the gate every path-taking op
+ * passes through.
+ *
+ * The counterpart of Rust `fs_proto::Roots`, with one structural difference the
+ * platform forces: on Linux a root is a filesystem path and the allowlist lives
+ * in a config file, whereas here a root is a **SAF tree the user picked** and
+ * the allowlist IS the set of persisted URI permissions Android already holds
+ * for us. Keeping the grant as the single source of truth means there is no way
+ * for our own bookkeeping to drift from what the OS will actually let us read —
+ * a second list could only ever be wrong in the dangerous direction.
+ *
+ * Per design doc §5, SAF trees are the default and the alarming
+ * `MANAGE_EXTERNAL_STORAGE` all-files permission is deliberately NOT requested:
+ * a paired phone is not thereby a phone that has handed over its whole disk.
+ *
+ * # Why paths are opaque URIs
+ *
+ * The protocol's `path` is a string the consumer only ever echoes back — it
+ * addresses children by the `path` in the [FsEntry] we sent. So a document URI
+ * serves directly as the path, and the phone needs no virtual namespace and no
+ * URI↔path map to keep in sync. The laptop shows `name`; it never has to parse
+ * `path`.
+ */
+class FsRoots(private val context: Context) {
+
+ /**
+ * One shared location.
+ *
+ * Two kinds, because the two grant models are genuinely different: a SAF
+ * tree is addressed by document URI and enumerated through a provider,
+ * while all-files access hands back the ordinary filesystem. Modelling them
+ * as one and converting would mean inventing a fake URI for real paths, or
+ * vice versa — both lossy.
+ */
+ sealed class Root {
+ abstract val name: String
+ abstract val writable: Boolean
+
+ data class Tree(
+ val treeUri: Uri,
+ override val name: String,
+ override val writable: Boolean,
+ ) : Root()
+
+ /** The whole of shared storage, available only while the user has
+ * granted all-files access. */
+ data class Local(
+ val dir: File,
+ override val name: String,
+ override val writable: Boolean,
+ ) : Root()
+ }
+
+ /** What a resolved path turned out to address. */
+ sealed class Target {
+ data class Doc(val uri: Uri) : Target()
+ data class Local(val file: File) : Target()
+ /**
+ * A share-sheet file. Kept apart from [Doc] because it is usually NOT
+ * a SAF document URI at all — a share commonly hands over a MediaStore
+ * or FileProvider URI, which has no document id and cannot be asked
+ * for children. Only opening and stat'ing it makes sense.
+ */
+ data class Shared(
+ val uri: Uri,
+ val name: String,
+ val size: Long,
+ /** Echoed back on CLOSE so the sender learns the file landed. */
+ val token: String,
+ ) : Target()
+ }
+
+ /**
+ * Whether the user has granted all-files access.
+ *
+ * Read from the OS, never cached and never mirrored into a preference of
+ * our own: the user can revoke it in system settings at any time, and a
+ * stale "on" would mean offering the laptop a root we can no longer read.
+ * The permission IS the setting — same rule as the SAF grants above.
+ */
+ fun allFilesGranted(): Boolean = try {
+ Environment.isExternalStorageManager()
+ } catch (_: Exception) {
+ false
+ }
+
+ /**
+ * The trees the user has granted us, newest first.
+ *
+ * Read live from the OS on every call rather than cached: a user can revoke
+ * a grant in system settings at any moment, and a cache would keep serving
+ * a folder that has actually been withdrawn.
+ */
+ fun roots(): List {
+ val out = ArrayList()
+ // All-files access supersedes the individual trees: offering both would
+ // show the same photo twice under two different paths, and the laptop
+ // has no way to know they are the same file.
+ if (allFilesGranted()) {
+ val shared = try {
+ @Suppress("DEPRECATION")
+ Environment.getExternalStorageDirectory()
+ } catch (_: Exception) {
+ null
+ }
+ if (shared != null && shared.isDirectory) {
+ out.add(Root.Local(shared, "Phone storage", writable = false))
+ return out
+ }
+ }
+ context.contentResolver.persistedUriPermissions
+ .filter { it.isReadPermission }
+ .forEach { perm ->
+ val uri = perm.uri
+ // Only tree grants: a single-document grant cannot be browsed
+ // and has no children to enumerate.
+ if (!DocumentsContract.isTreeUri(uri)) return@forEach
+ out.add(
+ Root.Tree(
+ treeUri = uri,
+ name = displayNameOf(uri),
+ // v1 is read-only end to end; the flag is carried so the
+ // laptop can show the folder as read-only rather than
+ // discovering it by failing a write.
+ writable = false,
+ ),
+ )
+ }
+ return out
+ }
+
+ fun isEmpty(): Boolean = roots().isEmpty()
+
+ /**
+ * Resolve a peer-supplied path to a document URI, or refuse it.
+ *
+ * This is the only place a peer-supplied string becomes something we will
+ * open, so it is the whole security boundary. A paired laptop must not be
+ * able to hand us an arbitrary `content://` URI and have us read it on its
+ * behalf — that would make this app a confused deputy for every provider it
+ * can reach, including other apps' and MediaStore's.
+ *
+ * The gate is the SAF tree id: a URI is acceptable only when it carries the
+ * same authority AND the same tree id as a grant we actually hold. That is
+ * exactly the boundary Android itself keys permissions on, so we can never
+ * reach past what the user picked. Containment *within* the tree is then
+ * enforced by the provider — a document id that is not really a child fails
+ * with SecurityException, which surfaces as [FsCode.ACCES].
+ */
+ fun resolve(path: String, forWrite: Boolean): Result {
+ if (path.isEmpty()) return Result.Err(FsCode.INVAL)
+ // An absolute path means the all-files root; anything else must be a
+ // content URI. Dispatching on the first character rather than trying
+ // both keeps the two gates separate, so neither can be reached by a
+ // path shaped for the other.
+ // A file the user shared through the share sheet, pulled by token. Not
+ // a browse: it is not under any root and never will be, because the
+ // authorisation is the share itself rather than a folder grant.
+ if (path.startsWith(SHARE_PREFIX)) {
+ if (forWrite) return Result.Err(FsCode.ROFS)
+ val token = path.removePrefix(SHARE_PREFIX)
+ val g = ShareGrants.get(token) ?: return Result.Err(FsCode.NOENT)
+ return Result.Ok(Target.Shared(g.uri, g.name, g.size, token))
+ }
+ if (path.startsWith("/")) return resolveLocal(path, forWrite)
+
+ val uri = try {
+ Uri.parse(path)
+ } catch (_: Exception) {
+ return Result.Err(FsCode.INVAL)
+ }
+ // A path that is not a content URI is not merely absent — it is a
+ // request we will never honour, so INVAL rather than NOENT.
+ if (uri.scheme != "content") return Result.Err(FsCode.INVAL)
+
+ val requestedTree = try {
+ DocumentsContract.getTreeDocumentId(uri)
+ } catch (_: Exception) {
+ null
+ } ?: return Result.Err(FsCode.ACCES)
+
+ for (root in roots()) {
+ if (root !is Root.Tree) continue
+ val grantedTree = try {
+ DocumentsContract.getTreeDocumentId(root.treeUri)
+ } catch (_: Exception) {
+ continue
+ }
+ if (uri.authority != root.treeUri.authority) continue
+ if (requestedTree != grantedTree) continue
+ if (forWrite && !root.writable) return Result.Err(FsCode.ROFS)
+ return Result.Ok(Target.Doc(uri))
+ }
+ // ACCES, not NOENT, and deliberately so: answering "no such file" for a
+ // path outside every root would let a paired peer probe for the
+ // existence of documents it is not allowed to see.
+ return Result.Err(FsCode.ACCES)
+ }
+
+ /**
+ * Resolve a real filesystem path under the all-files root.
+ *
+ * Canonicalises before comparing, so `..` traversal and symlinks pointing
+ * out of shared storage are rejected rather than merely discouraged. Being
+ * granted all-files access is not the same as agreeing to serve `/data` —
+ * the user turned on "any files" meaning their files, and this app can read
+ * a great deal more than that.
+ */
+ private fun resolveLocal(path: String, forWrite: Boolean): Result {
+ if (!allFilesGranted()) return Result.Err(FsCode.ACCES)
+ val canonical = try {
+ File(path).canonicalFile
+ } catch (_: Exception) {
+ return Result.Err(FsCode.NOENT)
+ }
+ for (root in roots()) {
+ if (root !is Root.Local) continue
+ val croot = try {
+ root.dir.canonicalFile
+ } catch (_: Exception) {
+ continue
+ }
+ val inside = canonical == croot ||
+ canonical.path.startsWith(croot.path + File.separator)
+ if (!inside) continue
+ if (forWrite && !root.writable) return Result.Err(FsCode.ROFS)
+ return Result.Ok(Target.Local(canonical))
+ }
+ return Result.Err(FsCode.ACCES)
+ }
+
+ /** Take a tree the user just picked, persisting the grant across reboots. */
+ fun grant(uri: Uri): Boolean = try {
+ context.contentResolver.takePersistableUriPermission(
+ uri,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION,
+ )
+ Log.i(TAG, "fs: now serving tree ${displayNameOf(uri)}")
+ true
+ } catch (e: Exception) {
+ Log.w(TAG, "fs: could not persist tree grant: ${e.message}")
+ false
+ }
+
+ /** Stop serving a tree. */
+ fun revoke(uri: Uri) {
+ try {
+ context.contentResolver.releasePersistableUriPermission(
+ uri,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION,
+ )
+ Log.i(TAG, "fs: stopped serving a tree")
+ } catch (e: Exception) {
+ Log.w(TAG, "fs: could not release tree grant: ${e.message}")
+ }
+ }
+
+ /**
+ * A human name for a tree.
+ *
+ * Falls back to the last path segment, which is ugly but never empty —
+ * the laptop shows this, and a blank folder name in a file manager is
+ * worse than a technical one.
+ */
+ private fun displayNameOf(treeUri: Uri): String {
+ val docUri = try {
+ DocumentsContract.buildDocumentUriUsingTree(
+ treeUri,
+ DocumentsContract.getTreeDocumentId(treeUri),
+ )
+ } catch (_: Exception) {
+ null
+ }
+ if (docUri != null) {
+ try {
+ context.contentResolver.query(
+ docUri,
+ arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME),
+ null,
+ null,
+ null,
+ )?.use { c ->
+ if (c.moveToFirst()) {
+ val n = c.getString(0)
+ if (!n.isNullOrBlank()) return n
+ }
+ }
+ } catch (_: Exception) {
+ // Fall through to the segment fallback.
+ }
+ }
+ return treeUri.lastPathSegment?.substringAfterLast(':')?.takeIf { it.isNotBlank() }
+ ?: "Shared folder"
+ }
+
+ sealed class Result {
+ data class Ok(val target: Target) : Result()
+ data class Err(val code: Int) : Result()
+ }
+
+ companion object {
+ private const val TAG = "VortexFs"
+
+ /** Addresses a share-sheet file rather than a browsable path. */
+ const val SHARE_PREFIX = "share:"
+ }
+}
diff --git a/android/app/src/main/java/com/vortex/a3/core/fs/FsServer.kt b/android/app/src/main/java/com/vortex/a3/core/fs/FsServer.kt
new file mode 100644
index 0000000..d6d64c2
--- /dev/null
+++ b/android/app/src/main/java/com/vortex/a3/core/fs/FsServer.kt
@@ -0,0 +1,460 @@
+package com.vortex.a3.core.fs
+
+import android.content.Context
+import android.net.Uri
+import android.provider.DocumentsContract
+import android.system.ErrnoException
+import android.system.Os
+import android.system.OsConstants
+import android.os.ParcelFileDescriptor
+import android.util.Log
+import java.io.File
+import org.json.JSONObject
+
+/**
+ * Serves the ranged-filesystem protocol against this phone's SAF-granted
+ * folders. The phone half of what Rust `fs_server` does for the laptop, and
+ * deliberately the same shape so the two can be read side by side.
+ *
+ * [FsProto] is the wire format, [FsRoots] is the policy gate, and this is the
+ * I/O. Everything here is synchronous and must run off the main thread: reads
+ * are bounded to [MAX_READ_LEN] so no single call is long, but a document
+ * provider backed by a cloud account can stall arbitrarily and must never block
+ * the BLE callback thread.
+ */
+class FsServer(
+ private val context: Context,
+ private val roots: FsRoots,
+ private val handles: FsHandles,
+) {
+
+ /**
+ * Called with a share token when the peer CLOSEs a share-sheet file it was
+ * reading — the one unambiguous "the laptop has the bytes" moment on this
+ * device, and what advances the share queue's progress.
+ *
+ * The old transfer got this from having just written the whole file onto
+ * the socket. A ranged pull has no such moment, so CLOSE stands in for it.
+ */
+ @Volatile
+ var onShareDelivered: (token: String) -> Unit = {}
+
+ /** What one served op produced. The caller turns this into frames — this
+ * class knows nothing about framing or transports. */
+ sealed class Served {
+ data class Meta(val reply: FsReply) : Served()
+ data class Data(val bytes: ByteArray) : Served()
+ data class Err(val err: FsErr) : Served()
+ }
+
+ private fun err(id: Int, code: Int, msg: String) = Served.Err(FsErr(id, code, msg))
+
+ /**
+ * Serve one request.
+ *
+ * Every branch answers. An op we do not implement is refused explicitly,
+ * never dropped: on the far side a silent op is indistinguishable from a
+ * dead link, and a file manager blocked on it hangs rather than reporting
+ * anything the user can act on.
+ */
+ fun serve(op: Byte, payload: ByteArray): Served {
+ val json = { JSONObject(String(payload, Charsets.UTF_8)) }
+ return try {
+ when (op) {
+ FsOp.LIST -> doList(ListReq.from(json()))
+ FsOp.STAT -> doStat(StatReq.from(json()))
+ FsOp.OPEN -> doOpen(OpenReq.from(json()))
+ FsOp.READ -> doRead(ReadReq.from(json()))
+ FsOp.CLOSE -> {
+ val r = CloseReq.from(json())
+ handles.remove(r.handle)?.let { token ->
+ ShareGrants.revoke(token)
+ try {
+ onShareDelivered(token)
+ } catch (e: Exception) {
+ Log.w(TAG, "onShareDelivered threw: ${e.message}")
+ }
+ }
+ // Not BADF for an unknown handle: we expire handles
+ // ourselves, so "already gone" is the state the caller
+ // asked for.
+ Served.Meta(FsReply.Ok(r.id))
+ }
+ FsOp.WRITE, FsOp.SETMETA -> {
+ // Read-only in v1 (design doc §3). Defined, wired, and
+ // honestly refused.
+ val id = try {
+ decodeWrite(payload)?.first?.id ?: json().optInt("id")
+ } catch (_: Exception) {
+ 0
+ }
+ err(id, FsCode.NOTSUP, "this device serves read-only")
+ }
+ else -> {
+ Log.d(TAG, "fs: unsupported op 0x${"%02x".format(op)}")
+ err(0, FsCode.NOTSUP, "unsupported op")
+ }
+ }
+ } catch (e: Exception) {
+ // A malformed request cannot be answered against its own id (we
+ // may have failed before reading one), so id 0 — which the
+ // protocol reserves for "no particular request".
+ Log.w(TAG, "fs: malformed op 0x${"%02x".format(op)}: ${e.message}")
+ err(0, FsCode.INVAL, "malformed request")
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // Ops
+ // -----------------------------------------------------------------------
+
+ private fun doList(r: ListReq): Served {
+ // The empty path is the synthetic root: it lists the granted folders
+ // themselves, so the laptop discovers what it may see instead of being
+ // told out of band. Same convention as the Rust server.
+ if (r.path.isEmpty() || r.path == "/") {
+ val all = roots.roots()
+ if (all.size != 1) {
+ return Served.Meta(
+ FsReply.ListPage(
+ r.id,
+ all.map {
+ FsEntry(
+ name = it.name,
+ isDir = true,
+ readonly = !it.writable,
+ path = rootPath(it),
+ )
+ },
+ cursor = null,
+ ),
+ )
+ }
+ // With exactly one folder, a synthetic level above it would be a
+ // directory the user clicks through every time for no information.
+ return when (val only = all[0]) {
+ is FsRoots.Root.Local -> listLocal(r.id, only.dir, r.cursor)
+ is FsRoots.Root.Tree ->
+ listChildren(
+ r.id,
+ treeRootUri(only.treeUri) ?: return err(r.id, FsCode.IO, "bad tree"),
+ r.cursor,
+ )
+ }
+ }
+ return when (val res = roots.resolve(r.path, forWrite = false)) {
+ is FsRoots.Result.Err -> err(r.id, res.code, "refused")
+ is FsRoots.Result.Ok -> when (val t = res.target) {
+ is FsRoots.Target.Doc -> listChildren(r.id, t.uri, r.cursor)
+ is FsRoots.Target.Local -> listLocal(r.id, t.file, r.cursor)
+ // A shared file is one file, by construction.
+ is FsRoots.Target.Shared -> err(r.id, FsCode.INVAL, "not a directory")
+ }
+ }
+ }
+
+ /** Directory listing over the all-files root. */
+ private fun listLocal(id: Int, dir: File, cursor: Int): Served {
+ if (!dir.isDirectory) return err(id, FsCode.INVAL, "not a directory")
+ // Sorted so paging is stable: listFiles has no defined order, and an
+ // unstable one would drop or repeat entries across pages.
+ val all = try {
+ dir.listFiles()?.sortedBy { it.name } ?: return err(id, FsCode.IO, "cannot list")
+ } catch (e: SecurityException) {
+ return err(id, FsCode.ACCES, "not granted")
+ } catch (e: Exception) {
+ return err(id, FsCode.IO, e.message ?: "list failed")
+ }
+ val page = all.drop(cursor).take(LIST_PAGE)
+ val next = if (cursor + page.size < all.size) cursor + page.size else null
+ return Served.Meta(FsReply.ListPage(id, page.map { localEntry(it) }, next))
+ }
+
+ private fun listChildren(id: Int, dirUri: Uri, cursor: Int): Served {
+ val docId = try {
+ if (DocumentsContract.isDocumentUri(context, dirUri)) {
+ DocumentsContract.getDocumentId(dirUri)
+ } else {
+ DocumentsContract.getTreeDocumentId(dirUri)
+ }
+ } catch (e: Exception) {
+ return err(id, FsCode.INVAL, "not a document: ${e.message}")
+ }
+ val childrenUri = try {
+ DocumentsContract.buildChildDocumentsUriUsingTree(dirUri, docId)
+ } catch (e: Exception) {
+ return err(id, FsCode.INVAL, "cannot address children: ${e.message}")
+ }
+
+ val entries = ArrayList()
+ var next: Int? = null
+ try {
+ context.contentResolver.query(childrenUri, PROJECTION, null, null, null)?.use { c ->
+ // Skip to the cursor. SAF has no offset query, so paging means
+ // re-walking — acceptable because pages are large and deep
+ // paging is rare, and it keeps a 10,000-entry folder off a
+ // single frame either way.
+ if (cursor > 0 && !c.moveToPosition(cursor - 1)) return@use
+ while (c.moveToNext()) {
+ if (entries.size >= LIST_PAGE) {
+ // Non-null cursor always means "call again" — never a
+ // guess, so the consumer can trust it as a terminator.
+ next = cursor + entries.size
+ break
+ }
+ entries.add(entryOf(c, dirUri))
+ }
+ } ?: return err(id, FsCode.IO, "provider returned no cursor")
+ } catch (e: SecurityException) {
+ return err(id, FsCode.ACCES, "not granted")
+ } catch (e: Exception) {
+ return err(id, FsCode.IO, e.message ?: "query failed")
+ }
+ return Served.Meta(FsReply.ListPage(id, entries, next))
+ }
+
+ private fun doStat(r: StatReq): Served {
+ if (r.path.isEmpty() || r.path == "/") {
+ // The synthetic root is a directory that exists but has no
+ // document behind it; answer without touching a provider.
+ return Served.Meta(FsReply.Stat(r.id, FsEntry(name = "/", isDir = true, readonly = true, path = "/")))
+ }
+ return when (val res = roots.resolve(r.path, forWrite = false)) {
+ is FsRoots.Result.Err -> err(r.id, res.code, "refused")
+ is FsRoots.Result.Ok -> when (val t = res.target) {
+ is FsRoots.Target.Shared -> Served.Meta(
+ FsReply.Stat(
+ r.id,
+ FsEntry(name = t.name, isDir = false, size = t.size, readonly = true, path = r.path),
+ ),
+ )
+ is FsRoots.Target.Local ->
+ if (!t.file.exists()) err(r.id, FsCode.NOENT, "no such file")
+ else Served.Meta(FsReply.Stat(r.id, localEntry(t.file)))
+ is FsRoots.Target.Doc -> {
+ val docUri = asDocumentUri(t.uri)
+ ?: return err(r.id, FsCode.INVAL, "not a document")
+ try {
+ context.contentResolver.query(docUri, PROJECTION, null, null, null)?.use { c ->
+ if (!c.moveToFirst()) return err(r.id, FsCode.NOENT, "no such document")
+ Served.Meta(FsReply.Stat(r.id, entryOf(c, t.uri)))
+ } ?: err(r.id, FsCode.NOENT, "no such document")
+ } catch (e: SecurityException) {
+ err(r.id, FsCode.ACCES, "not granted")
+ } catch (e: Exception) {
+ err(r.id, FsCode.IO, e.message ?: "stat failed")
+ }
+ }
+ }
+ }
+ }
+
+ private fun doOpen(r: OpenReq): Served {
+ if (r.write) return err(r.id, FsCode.ROFS, "this device serves read-only")
+ val target = when (val res = roots.resolve(r.path, forWrite = false)) {
+ is FsRoots.Result.Err -> return err(r.id, res.code, "refused")
+ is FsRoots.Result.Ok -> res.target
+ }
+ return when (target) {
+ is FsRoots.Target.Local -> openLocal(r.id, target.file)
+ is FsRoots.Target.Doc -> openDoc(r.id, target.uri)
+ is FsRoots.Target.Shared ->
+ openShared(r.id, target.uri, target.size, target.token)
+ }
+ }
+
+ /**
+ * Open a share-sheet file. Straight to the resolver: no document query
+ * first, because the URI may be a MediaStore or FileProvider one that
+ * answers none of the Document columns.
+ */
+ private fun openShared(id: Int, uri: Uri, declaredSize: Long, token: String): Served {
+ val pfd = try {
+ context.contentResolver.openFileDescriptor(uri, "r")
+ } catch (e: SecurityException) {
+ // The one-off grant the share gave us has lapsed — Android drops it
+ // when the sharing task finishes.
+ return err(id, FsCode.ACCES, "share permission expired")
+ } catch (e: java.io.FileNotFoundException) {
+ return err(id, FsCode.NOENT, "shared file is gone")
+ } catch (e: Exception) {
+ return err(id, FsCode.IO, e.message ?: "open failed")
+ } ?: return err(id, FsCode.IO, "provider returned no descriptor")
+ // Prefer what the descriptor says over what the provider claimed at
+ // share time: statSize is the length we will actually be able to read.
+ val size = try { pfd.statSize.coerceAtLeast(0) } catch (_: Exception) { 0 }
+ return finishOpen(id, pfd, if (size > 0) size else declaredSize.coerceAtLeast(0), token)
+ }
+
+ private fun openLocal(id: Int, f: File): Served {
+ if (f.isDirectory) return err(id, FsCode.ISDIR, "is a directory")
+ if (!f.exists()) return err(id, FsCode.NOENT, "no such file")
+ val pfd = try {
+ ParcelFileDescriptor.open(f, ParcelFileDescriptor.MODE_READ_ONLY)
+ } catch (e: SecurityException) {
+ return err(id, FsCode.ACCES, "not granted")
+ } catch (e: java.io.FileNotFoundException) {
+ return err(id, FsCode.NOENT, "no such file")
+ } catch (e: Exception) {
+ return err(id, FsCode.IO, e.message ?: "open failed")
+ }
+ return finishOpen(id, pfd, f.length())
+ }
+
+ private fun openDoc(id: Int, uri: Uri): Served {
+ val docUri = asDocumentUri(uri) ?: return err(id, FsCode.INVAL, "not a document")
+ var size = 0L
+ try {
+ context.contentResolver.query(docUri, PROJECTION, null, null, null)?.use { c ->
+ if (c.moveToFirst()) {
+ if (isDir(c)) return err(id, FsCode.ISDIR, "is a directory")
+ size = c.getLong(IDX_SIZE)
+ }
+ }
+ } catch (_: Exception) {
+ // Size is advisory — the open below is the real test.
+ }
+ val pfd = try {
+ context.contentResolver.openFileDescriptor(docUri, "r")
+ } catch (e: SecurityException) {
+ return err(id, FsCode.ACCES, "not granted")
+ } catch (e: java.io.FileNotFoundException) {
+ return err(id, FsCode.NOENT, "no such document")
+ } catch (e: Exception) {
+ return err(id, FsCode.IO, e.message ?: "open failed")
+ } ?: return err(id, FsCode.IO, "provider returned no descriptor")
+
+ if (size <= 0) size = try { pfd.statSize.coerceAtLeast(0) } catch (_: Exception) { 0 }
+ return finishOpen(id, pfd, size)
+ }
+
+ private fun finishOpen(
+ id: Int,
+ pfd: ParcelFileDescriptor,
+ size: Long,
+ shareToken: String? = null,
+ ): Served {
+ val handle = handles.insert(pfd, size, shareToken)
+ if (handle == null) {
+ // Close what we just opened: refusing the request must not also
+ // leak the descriptor that made us refuse it.
+ try { pfd.close() } catch (_: Exception) {}
+ return err(id, FsCode.IO, "too many open handles")
+ }
+ return Served.Meta(FsReply.Open(id, handle, size, readonly = true))
+ }
+
+ private fun doRead(r: ReadReq): Served {
+ if (r.len < 0 || r.offset < 0) return err(r.id, FsCode.INVAL, "negative read")
+ val (pfd, size) = handles.get(r.handle)
+ ?: return err(r.id, FsCode.BADF, "unknown or expired handle")
+
+ val want = minOf(r.len, MAX_READ_LEN)
+ if (want == 0) return Served.Data(encodeData(r.id, r.offset, eof = r.offset >= size, ByteArray(0)))
+ if (size > 0 && r.offset >= size) {
+ // Reading at or past the end is a normal way to discover EOF, not
+ // an error — answer with an empty, EOF-flagged frame.
+ return Served.Data(encodeData(r.id, r.offset, eof = true, ByteArray(0)))
+ }
+
+ val buf = ByteArray(want)
+ val n = try {
+ // Positional read: pread does not disturb a shared file offset, so
+ // concurrent ranged reads on one handle cannot interleave into each
+ // other's bytes. A thumbnailer firing parallel reads makes that a
+ // real case, not a theoretical one.
+ Os.pread(pfd.fileDescriptor, buf, 0, want, r.offset)
+ } catch (e: ErrnoException) {
+ return if (e.errno == OsConstants.ESPIPE) {
+ // Some providers (cloud-backed documents) hand back a pipe,
+ // which cannot seek. Honest refusal beats silently returning
+ // the wrong bytes.
+ err(r.id, FsCode.IO, "document is not seekable")
+ } else {
+ err(r.id, FsCode.IO, "pread: ${e.message}")
+ }
+ } catch (e: Exception) {
+ return err(r.id, FsCode.IO, e.message ?: "read failed")
+ }
+
+ if (n <= 0) return Served.Data(encodeData(r.id, r.offset, eof = true, ByteArray(0)))
+ val eof = if (size > 0) r.offset + n >= size else n < want
+ return Served.Data(encodeData(r.id, r.offset, eof, buf, n))
+ }
+
+ // -----------------------------------------------------------------------
+ // Helpers
+ // -----------------------------------------------------------------------
+
+ /** A tree URI addressed as the document it stands for, so children can be
+ * enumerated from it. */
+ private fun treeRootUri(treeUri: Uri): Uri? = try {
+ DocumentsContract.buildDocumentUriUsingTree(
+ treeUri,
+ DocumentsContract.getTreeDocumentId(treeUri),
+ )
+ } catch (_: Exception) {
+ null
+ }
+
+ /** The address a peer should send back to enter this root. A document URI
+ * for a SAF tree; an ordinary absolute path for the all-files root. */
+ private fun rootPath(root: FsRoots.Root): String = when (root) {
+ is FsRoots.Root.Tree -> (treeRootUri(root.treeUri) ?: root.treeUri).toString()
+ is FsRoots.Root.Local -> root.dir.absolutePath
+ }
+
+ private fun localEntry(f: File): FsEntry = FsEntry(
+ name = f.name,
+ isDir = f.isDirectory,
+ size = if (f.isDirectory) 0 else f.length(),
+ // The protocol carries seconds; File reports milliseconds.
+ mtime = f.lastModified() / 1000,
+ readonly = true,
+ path = f.absolutePath,
+ )
+
+ /** Peer-supplied URIs are already tree-document URIs (we only ever emit
+ * those), but a bare tree URI is accepted too so the laptop can address a
+ * root by the path it was given. */
+ private fun asDocumentUri(uri: Uri): Uri? =
+ if (DocumentsContract.isDocumentUri(context, uri)) uri else treeRootUri(uri)
+
+ private fun isDir(c: android.database.Cursor): Boolean =
+ c.getString(IDX_MIME) == DocumentsContract.Document.MIME_TYPE_DIR
+
+ private fun entryOf(c: android.database.Cursor, parent: Uri): FsEntry {
+ val docId = c.getString(IDX_ID)
+ val dir = isDir(c)
+ return FsEntry(
+ name = c.getString(IDX_NAME) ?: docId ?: "?",
+ isDir = dir,
+ size = if (dir) 0 else c.getLong(IDX_SIZE),
+ // The protocol carries seconds; SAF reports milliseconds.
+ mtime = c.getLong(IDX_MTIME) / 1000,
+ readonly = true,
+ path = try {
+ DocumentsContract.buildDocumentUriUsingTree(parent, docId).toString()
+ } catch (_: Exception) {
+ ""
+ },
+ )
+ }
+
+ companion object {
+ private const val TAG = "VortexFs"
+
+ private val PROJECTION = arrayOf(
+ DocumentsContract.Document.COLUMN_DOCUMENT_ID,
+ DocumentsContract.Document.COLUMN_DISPLAY_NAME,
+ DocumentsContract.Document.COLUMN_MIME_TYPE,
+ DocumentsContract.Document.COLUMN_SIZE,
+ DocumentsContract.Document.COLUMN_LAST_MODIFIED,
+ )
+ private const val IDX_ID = 0
+ private const val IDX_NAME = 1
+ private const val IDX_MIME = 2
+ private const val IDX_SIZE = 3
+ private const val IDX_MTIME = 4
+ }
+}
diff --git a/android/app/src/main/java/com/vortex/a3/core/fs/ShareGrants.kt b/android/app/src/main/java/com/vortex/a3/core/fs/ShareGrants.kt
new file mode 100644
index 0000000..8668e08
--- /dev/null
+++ b/android/app/src/main/java/com/vortex/a3/core/fs/ShareGrants.kt
@@ -0,0 +1,78 @@
+package com.vortex.a3.core.fs
+
+import android.net.Uri
+import android.util.Log
+
+/**
+ * Files the user has explicitly shared with Vortex, addressable by an opaque
+ * token so the laptop can pull them through the ranged-read protocol.
+ *
+ * This exists because a shared file is authorised differently from a browsed
+ * one. Browsing is gated by [FsRoots]: a SAF tree the user picked, or all-files
+ * access. A share-sheet file is neither — it arrives as a one-off URI grant to
+ * this process, and the *act of sharing* is the authorisation. So it gets its
+ * own, deliberately narrow gate: exactly the files the user sent, addressed by
+ * a token they cannot be guessed from, and nothing else.
+ *
+ * Tokens are random rather than a content hash. The old store keyed blobs by
+ * sha256 of the bytes, which meant hashing — and therefore reading — the whole
+ * file before it could be offered. That is the buffering this change removes,
+ * so the token cannot depend on the content.
+ */
+object ShareGrants {
+
+ /** One shared file: what to read, and what to call it. */
+ data class Grant(val uri: Uri, val name: String, val mime: String, val size: Long)
+
+ /**
+ * How many shares stay addressable. Matches the old blob store's ceiling,
+ * and for the same reason: the laptop pulls one file at a time, so a grant
+ * evicted before its turn is a file that silently never arrives. Callers
+ * cap a batch at this (see ShareReceiverActivity.MAX_SHARE_FILES).
+ *
+ * Unlike the old store, holding this many costs a URI each rather than a
+ * file each — 32 entries used to mean up to 2 GB of heap.
+ */
+ const val MAX_ENTRIES = 32
+
+ private const val TAG = "VortexFs"
+
+ // Insertion-ordered so eviction drops the oldest first.
+ private val grants = LinkedHashMap()
+
+ /** Register [uri] as shared; returns the token the laptop pulls it by. */
+ @Synchronized
+ fun grant(uri: Uri, name: String, mime: String, size: Long): String {
+ val token = randomToken()
+ grants[token] = Grant(uri, name, mime, size)
+ while (grants.size > MAX_ENTRIES) {
+ val oldest = grants.keys.iterator().next()
+ grants.remove(oldest)
+ }
+ return token
+ }
+
+ /** The grant for [token], or null when unknown or evicted. */
+ @Synchronized
+ fun get(token: String): Grant? = if (token.isEmpty()) null else grants[token]
+
+ /** Forget a grant once the laptop has the file. */
+ @Synchronized
+ fun revoke(token: String) {
+ if (grants.remove(token) != null) Log.i(TAG, "share grant spent")
+ }
+
+ @Synchronized
+ fun size(): Int = grants.size
+
+ /**
+ * 16 bytes of randomness, hex. Unguessable on purpose: this token is the
+ * only thing standing between a paired laptop and a file the user shared
+ * with it, and the peer supplies it verbatim.
+ */
+ private fun randomToken(): String {
+ val b = ByteArray(16)
+ java.security.SecureRandom().nextBytes(b)
+ return b.joinToString("") { "%02x".format(it) }
+ }
+}
diff --git a/android/app/src/main/java/com/vortex/a3/core/handoff/HandoffEvent.kt b/android/app/src/main/java/com/vortex/a3/core/handoff/HandoffEvent.kt
index ee90c28..035dfca 100644
--- a/android/app/src/main/java/com/vortex/a3/core/handoff/HandoffEvent.kt
+++ b/android/app/src/main/java/com/vortex/a3/core/handoff/HandoffEvent.kt
@@ -12,12 +12,20 @@ import org.json.JSONObject
* @param appId source app package (e.g. com.android.chrome) for its icon
* @param openNow true = an explicit Share → the laptop opens it immediately;
* false = the live accessibility read → a "continue" pill
+ * @param id identity of an [openNow] request, so the laptop opens it
+ * exactly once. The event also rides the AppState snapshot as a
+ * backstop for a dead BLE link, and that snapshot is
+ * republished on every heartbeat — without an identity the
+ * laptop cannot tell a re-assert from a fresh share, and
+ * re-opened the browser every ~12s forever. Left empty on the
+ * live-read path (the laptop only dedups the open path).
*/
data class HandoffEvent(
val url: String,
val title: String = "",
val appId: String = "",
val openNow: Boolean = false,
+ val id: String = "",
) {
fun toJsonBytes(): ByteArray {
val o = JSONObject()
@@ -25,6 +33,7 @@ data class HandoffEvent(
if (title.isNotEmpty()) o.put("title", title)
if (appId.isNotEmpty()) o.put("app_id", appId)
o.put("open_now", openNow)
+ if (id.isNotEmpty()) o.put("id", id)
return o.toString().toByteArray(Charsets.UTF_8)
}
}
diff --git a/android/app/src/main/java/com/vortex/a3/core/lan/LanServer.kt b/android/app/src/main/java/com/vortex/a3/core/lan/LanServer.kt
index a3aa055..26baaa2 100644
--- a/android/app/src/main/java/com/vortex/a3/core/lan/LanServer.kt
+++ b/android/app/src/main/java/com/vortex/a3/core/lan/LanServer.kt
@@ -101,6 +101,63 @@ class LanServer(
* the stack can gate the redundant BLE burst. */
var onBulkDelivered: (key: String, hash: String) -> Unit = { _, _ -> }
+ /**
+ * Serve one filesystem op (FS_REQ 0x50) and return the reply as
+ * `(frame type, payload)`, or null when there is no server wired.
+ *
+ * Wired by VortexStack to the SAME [com.vortex.a3.core.fs.FsServer] the BLE
+ * path uses, deliberately: handles are minted by OPEN and used by later
+ * READs, and the laptop may switch transports between the two — it prefers
+ * Wi-Fi and falls back to Bluetooth on failure. A per-transport handle
+ * table would turn that fallback into a BADF in the middle of a file.
+ */
+ var fsServe: ((op: Byte, payload: ByteArray) -> Pair)? = null
+
+ /**
+ * A reply to a filesystem request WE sent, arriving over this socket.
+ *
+ * Needed because a reply does not necessarily come back on the transport
+ * that carried the request: the laptop's client prefers Wi-Fi for FS
+ * traffic, so a request sent over BLE is answered over TCP. Without this
+ * the phone dropped every such reply and its browser sat until the 20 s
+ * timeout, reporting "the laptop did not answer" while the laptop had in
+ * fact answered immediately.
+ */
+ var onFsReply: ((frameType: Byte, payload: ByteArray) -> Unit)? = null
+
+ /**
+ * Writer for the LAN session the laptop is using for filesystem traffic,
+ * or null when there is none open.
+ *
+ * Bound to the connection that has actually carried an FS frame, not to
+ * whichever connection is newest: the laptop also opens short-lived
+ * heartbeat sessions, and publishing one of those would send a request down
+ * a socket about to close.
+ */
+ @Volatile
+ private var fsWriter: ((op: Byte, payload: ByteArray) -> Unit)? = null
+
+ /**
+ * Send one FS_REQ over the live LAN session. False when there is none, so
+ * the caller can fall back to BLE.
+ *
+ * This works because the session is a plain bidirectional socket: the
+ * laptop dials it and serves whatever arrives on it, whichever side asked.
+ * The phone cannot open one itself — the laptop has no listener — so the
+ * first request of a browse still goes over BLE, and the laptop's own reply
+ * is what brings the LAN session up for everything after it.
+ */
+ fun fsSend(op: Byte, payload: ByteArray): Boolean {
+ val w = fsWriter ?: return false
+ return try {
+ w(op, payload)
+ true
+ } catch (e: Exception) {
+ Log.w(TAG, "fs: LAN send failed (${e.message}); caller falls back")
+ false
+ }
+ }
+
/** Fired after an instant-share FILE blob has been written to the peer,
* with the content token it pulled by. Closes the loop the outgoing-offer
* watchdog waits on: an offer is only really done once the laptop has the
@@ -694,6 +751,13 @@ class LanServer(
} finally { outLock.unlock() }
}
+ // Writes an FS_REQ on THIS connection. Published only once an
+ // FS frame has arrived here (below), so it can never be a
+ // heartbeat socket.
+ val fsOut: (Byte, ByteArray) -> Unit = { op, payloadBytes ->
+ lockedSealAndWrite(FrameType.FS_REQ, op, payloadBytes)
+ }
+
val writer: suspend (com.vortex.a3.core.earbuds.AudioOpFrame) -> Result =
{ outFrame ->
try {
@@ -837,50 +901,6 @@ class LanServer(
}
continue
}
- // Instant-share file pull: serve the stashed blob
- // reliably over TCP as CLIPBOARD_FILE chunks.
- if (key == "clipboard_file") {
- val token = req.optString(key, "")
- // A stashed blob, or — when the token is a
- // document URI — a file the laptop picked
- // out of a browsed folder. One pull path
- // for both: the transfer, the chunking and
- // the laptop's save are already proven, and
- // a browsed file is not a different kind of
- // file just because it was asked for.
- val blob = com.vortex.a3.core.clipboard.ClipboardBlobStore
- .getByToken(token)
- ?: com.vortex.a3.core.files.PhoneFiles
- .read(context, token)?.bytes
- if (blob == null) {
- Log.i(TAG, "bulk-sync: clipboard_file token=$token not found")
- status.put(key, "nomatch")
- } else {
- // Extends the hot window: the laptop
- // comes back for the NEXT queued file
- // in a fresh round moments from now.
- keepLanHot()
- sendChunked(FrameType.CLIPBOARD_FILE, blob)
- Log.i(TAG, "bulk-sync: clipboard_file sent (${blob.size} bytes)")
- status.put(key, "sent")
- try { onFileServed(token) } catch (e: Exception) {
- Log.w(TAG, "onFileServed listener threw: ${e.message}")
- }
- }
- continue
- }
- // Folder listing: the value is the document
- // URI to look inside, or "" for the roots the
- // user has granted.
- if (key == "browse") {
- val at = req.optString(key, "")
- val json = com.vortex.a3.core.files.PhoneFiles.list(context, at)
- keepLanHot()
- sendChunked(FrameType.PHONE_FILES, json)
- Log.i(TAG, "bulk-sync: listing sent (${json.size} bytes)")
- status.put(key, "sent")
- continue
- }
// Watermark datasets: the value is "everything
// up to " rather than a content hash.
val historyFrameType = when (key) {
@@ -890,16 +910,23 @@ class LanServer(
}
if (historyFrameType != null) {
val since = req.optString(key, "").toLongOrNull() ?: 0L
- // A denied READ_SMS / READ_CALL_LOG throws here
- // (ContentResolver read). Catch it so ONE missing
- // permission can't kill the whole bulk-sync
- // connection (which left the laptop on stale data
- // with a repeating "early eof"): mark this dataset
- // errored and move on, still reaching the done frame.
+ // Any ContentResolver read can throw here — a denied
+ // READ_SMS / READ_CALL_LOG, but also a query the
+ // provider itself refuses. Catch it so ONE failing
+ // dataset can't kill the whole bulk-sync connection
+ // (which left the laptop on stale data with a
+ // repeating "early eof"): mark this dataset errored
+ // and move on, still reaching the done frame.
val json = try {
historyProvider(key, since)
} catch (e: Exception) {
- Log.w(TAG, "bulk-sync: $key history provider threw (permission denied?): ${e.message}")
+ // Name the exception class instead of guessing a
+ // cause: a denied permission is a
+ // SecurityException, a rejected query an
+ // IllegalArgumentException. Logging both as
+ // "permission denied?" sent us hunting the wrong
+ // bug for a sortOrder the provider wouldn't take.
+ Log.w(TAG, "bulk-sync: $key history provider threw ${e.javaClass.simpleName}: ${e.message}")
status.put(key, "error")
continue
}
@@ -986,6 +1013,51 @@ class LanServer(
status.toString().toByteArray(Charsets.UTF_8),
)
}
+ frame.type == FrameType.FS_META ||
+ frame.type == FrameType.FS_DATA ||
+ frame.type == FrameType.FS_ERR -> {
+ val plain = runCatching {
+ aeadOpen(pair.receiver, frame.payload)
+ }.getOrNull()
+ if (plain == null) {
+ Log.w(TAG, "fs: reply AEAD decrypt failed")
+ continue
+ }
+ // This socket is demonstrably the laptop's FS
+ // session, so it is the one to send our own
+ // requests on — Wi-Fi instead of BLE for everything
+ // after the first.
+ fsWriter = fsOut
+ try {
+ onFsReply?.invoke(frame.type, plain)
+ } catch (e: Exception) {
+ Log.w(TAG, "onFsReply threw: ${e.message}")
+ }
+ }
+ frame.type == FrameType.FS_REQ -> {
+ // Ranged filesystem op over Wi-Fi. The laptop
+ // prefers this transport because BLE caps a notify
+ // at 512 bytes: a 48 KiB read is ~96 paced
+ // fragments there and a single frame here.
+ val plain = runCatching {
+ aeadOpen(pair.receiver, frame.payload)
+ }.getOrNull()
+ if (plain == null) {
+ Log.w(TAG, "fs: AEAD decrypt failed")
+ continue
+ }
+ val serve = fsServe
+ if (serve == null) {
+ Log.w(TAG, "fs: no server wired; ignoring op 0x${"%02x".format(frame.sub)}")
+ continue
+ }
+ // Serving touches the disk and runs on this
+ // connection's thread, which is what we want: it
+ // serialises the ops on this socket and cannot
+ // stall any other peer's connection.
+ val (type, bytes) = serve(frame.sub, plain)
+ lockedSealAndWrite(type, 0x00, bytes)
+ }
frame.type == FrameType.AUDIO_OP -> {
// Earbuds-switch frame (Phase 1). AEAD-decrypt
// the payload, decode the AudioOpFrame JSON,
@@ -1133,6 +1205,10 @@ class LanServer(
// its writer.
com.vortex.a3.core.earbuds.EarbudsSwitchHolder
.clearSessionWriter(peerPubFinal, writer)
+ // Same CAS discipline: only clear the FS slot if this
+ // connection still owns it, or we would strip a newer
+ // session of its writer on our way out.
+ if (fsWriter === fsOut) fsWriter = null
}
}
} catch (e: Exception) {
diff --git a/android/app/src/main/java/com/vortex/a3/core/media/CapturedMediaWatcher.kt b/android/app/src/main/java/com/vortex/a3/core/media/CapturedMediaWatcher.kt
index e9362aa..a9d405c 100644
--- a/android/app/src/main/java/com/vortex/a3/core/media/CapturedMediaWatcher.kt
+++ b/android/app/src/main/java/com/vortex/a3/core/media/CapturedMediaWatcher.kt
@@ -364,14 +364,12 @@ data class CapturedMedia(
continue
}
val size = if (sizeIdx >= 0) c.getLong(sizeIdx) else 0L
- // Reject on the row's own SIZE, before anything opens the
- // file. A recording is orders of magnitude bigger than a
- // screenshot, and the reader would otherwise pull it into
- // the service's heap to find out it was too big.
- if (size > com.vortex.a3.core.clipboard.ClipboardFileReader.MAX_FILE_BYTES) {
- Log.i(tag, "${kind.name.lowercase()} _id=$id is $size bytes — over the cap, not sent")
- continue
- }
+ // No size cap any more. There used to be one because the
+ // reader pulled the whole file into the service's heap to
+ // send it — which is exactly what the ranged-read protocol
+ // removed: an offer now carries a grant, and the laptop
+ // streams the bytes on demand. A screen recording is
+ // offered like anything else, and nothing here holds it.
val name = (if (nameIdx >= 0) c.getString(nameIdx) else null)
?.takeIf { it.isNotBlank() } ?: "capture-$id"
val mime = (if (mimeIdx >= 0) c.getString(mimeIdx) else null)
diff --git a/android/app/src/main/java/com/vortex/a3/core/pairing/ReconnectOrchestrator.kt b/android/app/src/main/java/com/vortex/a3/core/pairing/ReconnectOrchestrator.kt
index 6f99b78..4e55a82 100644
--- a/android/app/src/main/java/com/vortex/a3/core/pairing/ReconnectOrchestrator.kt
+++ b/android/app/src/main/java/com/vortex/a3/core/pairing/ReconnectOrchestrator.kt
@@ -228,6 +228,18 @@ class ReconnectOrchestrator(
Log.i(TAG, " peer_static_pub = ${peerStaticPub.toHexPrefix()}")
Log.i(TAG, " transcript_hash = ${transcript.toHexPrefix()}")
+ // IK proved this address really is this peer, so it is safe to record
+ // (before IK we would only be trusting a presence-token match). This
+ // also backfills pairings made before the address was persisted at
+ // pair time, so Forget can clean their bonds without re-pairing.
+ runCatching { peerStore.savePeerBtAddr(peerStaticPub, device.address) }
+ .onSuccess {
+ // Prefix only, per the redaction rule above: enough to confirm
+ // which laptop was recorded, not enough to correlate.
+ Log.i(TAG, " peer_bt_addr = ${device.address.take(8)}…")
+ }
+ .onFailure { Log.w(TAG, "could not persist peer BT addr: ${it.message}") }
+
states[device.address] = IkState.Established(peerStaticPub, transcript)
// CopyOnWriteArrayList — iteration is lock-free over a snapshot,
// safe to interleave with add()/forgetDevice() on other threads.
diff --git a/android/app/src/main/java/com/vortex/a3/core/sms/SmsProvider.kt b/android/app/src/main/java/com/vortex/a3/core/sms/SmsProvider.kt
index 7c3a8c8..2468dc8 100644
--- a/android/app/src/main/java/com/vortex/a3/core/sms/SmsProvider.kt
+++ b/android/app/src/main/java/com/vortex/a3/core/sms/SmsProvider.kt
@@ -118,12 +118,14 @@ class SmsProvider(
val skip = offset.coerceAtLeast(0)
return querySms(
cols, sel, args,
- "${Telephony.Sms.DATE} DESC LIMIT $cap OFFSET $skip",
+ "${Telephony.Sms.DATE} DESC",
// Some ROMs leave ADDRESS blank on sent rows; fall back to the
// requested address so the laptop's thread merge (keyed by
// address) doesn't silently drop them.
fallbackAddress = address.trim(),
logTag = "loadThread",
+ cap = cap,
+ skip = skip,
)
}
@@ -149,8 +151,9 @@ class SmsProvider(
cols,
"${Telephony.Sms.DATE} > ?",
arrayOf(sinceMs.toString()),
- "${Telephony.Sms.DATE} ASC LIMIT $cap",
+ "${Telephony.Sms.DATE} ASC",
logTag = "readHistorySince",
+ cap = cap,
)
}
@@ -181,6 +184,23 @@ class SmsProvider(
}
/** Shared cursor loop for the thread/history readers. */
+ /**
+ * Run an SMS query and window the result in Kotlin.
+ *
+ * [cap] and [skip] are applied HERE rather than as `LIMIT`/`OFFSET` in
+ * [sortOrder]. A provider is free to validate that argument and reject the
+ * clause: the call-log provider does exactly that on Android 16
+ * (`Invalid token LIMIT`), which silently broke every call-log read until
+ * it was moved Kotlin-side. `content://sms` happens to tolerate it today,
+ * so this is prophylactic — but the failure mode there would be worse than
+ * the call log's, because callers rely on the window for correctness and
+ * not merely for size: [loadThread] pages with it, and an unwindowed
+ * [readHistorySince] would ship the whole message store in one batch.
+ *
+ * Cost of doing it here: [skip] is a cursor walk instead of a SQL OFFSET,
+ * so deep paging is O(offset). Thread pages cap at 200, so this is a walk
+ * over a lazily-filled cursor window, not per-row I/O.
+ */
private fun querySms(
cols: Array,
selection: String,
@@ -188,8 +208,10 @@ class SmsProvider(
sortOrder: String,
fallbackAddress: String = "",
logTag: String,
+ cap: Int,
+ skip: Int = 0,
): List {
- val out = ArrayList()
+ val out = ArrayList(minOf(cap, LIMIT))
try {
context.contentResolver.query(
Uri.parse("content://sms"), cols, selection, args, sortOrder,
@@ -201,7 +223,14 @@ class SmsProvider(
val dateIdx = c.getColumnIndex(Telephony.Sms.DATE)
val threadIdx = c.getColumnIndex(Telephony.Sms.THREAD_ID)
val readIdx = c.getColumnIndex(Telephony.Sms.READ)
+ var skipped = 0
while (c.moveToNext()) {
+ // Window in this order: drop `skip` rows, then take `cap`.
+ if (skipped < skip) {
+ skipped++
+ continue
+ }
+ if (out.size >= cap) break
out.add(
SmsMessage(
id = if (idIdx >= 0) c.getString(idIdx).orEmpty() else "",
@@ -251,7 +280,7 @@ class SmsProvider(
cols,
null,
null,
- "${Telephony.Sms.DATE} DESC LIMIT $LIMIT",
+ "${Telephony.Sms.DATE} DESC",
)?.use { c ->
val idIdx = c.getColumnIndex(Telephony.Sms._ID)
val addrIdx = c.getColumnIndex(Telephony.Sms.ADDRESS)
@@ -261,7 +290,7 @@ class SmsProvider(
val threadIdx = c.getColumnIndex(Telephony.Sms.THREAD_ID)
val readIdx = c.getColumnIndex(Telephony.Sms.READ)
while (c.moveToNext()) {
- if (out.size >= LIMIT) break // guard if the SQL LIMIT is ignored
+ if (out.size >= LIMIT) break // sole row limit: see querySms
out.add(
SmsMessage(
id = if (idIdx >= 0) c.getString(idIdx).orEmpty() else "",
diff --git a/android/app/src/main/java/com/vortex/a3/core/storage/EncryptedPrefsPeerStore.kt b/android/app/src/main/java/com/vortex/a3/core/storage/EncryptedPrefsPeerStore.kt
index 9fc71c2..e66131a 100644
--- a/android/app/src/main/java/com/vortex/a3/core/storage/EncryptedPrefsPeerStore.kt
+++ b/android/app/src/main/java/com/vortex/a3/core/storage/EncryptedPrefsPeerStore.kt
@@ -141,6 +141,25 @@ interface PeerStore {
commitAudioInNonce(peerStaticPub, nonce)
return true
}
+
+ /**
+ * The laptop's Bluetooth address (BD_ADDR string), learned from the
+ * connected central during pairing and refreshed on every reconnect.
+ *
+ * Exists so [forget] can hand it to
+ * [com.vortex.a3.core.ble.BondCleaner.removeBond]. Vortex itself never
+ * bonds — Linux deliberately skips `Device::pair()` — but a bond can
+ * still appear via the desktop's Bluetooth panel or an older build, and
+ * Android hides such profile-less LE bonds from Settings, so the user
+ * cannot clear them by hand. Without this the phone keeps its half of a
+ * bond the laptop has dropped, and the next pairing dies during
+ * encryption with `timeout: service discovery`.
+ *
+ * Laptops use a public static address, so one value per peer is stable —
+ * unlike the phone's own rotating RPA.
+ */
+ fun loadPeerBtAddr(peerStaticPub: ByteArray): String? = null
+ fun savePeerBtAddr(peerStaticPub: ByteArray, addr: String) {}
}
/** EncryptedSharedPreferences-backed Trusted Peer store. */
@@ -182,6 +201,13 @@ class EncryptedPrefsPeerStore(context: Context) : PeerStore {
runCatching { TrustedPeer.decode(Base64.decode(s, Base64.NO_WRAP)) }.getOrNull()
}
.filterNotNull()
+ // Deterministic order. `prefs.all` is a HashMap, so without this the
+ // sequence follows hash order — and every `list().firstOrNull()`
+ // caller silently means "an arbitrary peer". That showed up as the
+ // phone's home card displaying whichever laptop hashed first
+ // instead of the one it was actually connected to. Most recently
+ // paired first, so "the one peer" degrades to something sensible.
+ .sortedByDescending { it.pairedAt }
.toList()
}
@@ -192,9 +218,17 @@ class EncryptedPrefsPeerStore(context: Context) : PeerStore {
.remove("counter-$hex")
.remove("audio_out_nonce-$hex")
.remove("audio_in_nonce-$hex")
+ .remove("btaddr-$hex")
.apply()
}
+ override fun loadPeerBtAddr(peerStaticPub: ByteArray): String? =
+ prefs.getString("btaddr-${peerStaticPub.toHex()}", null)
+
+ override fun savePeerBtAddr(peerStaticPub: ByteArray, addr: String) {
+ prefs.edit().putString("btaddr-${peerStaticPub.toHex()}", addr).apply()
+ }
+
override fun loadCounter(peerStaticPub: ByteArray): Long {
val key = "counter-${peerStaticPub.toHex()}"
// The read alone needs no lock — SharedPreferences is internally
diff --git a/android/app/src/main/java/com/vortex/a3/service/ShareQueue.kt b/android/app/src/main/java/com/vortex/a3/service/ShareQueue.kt
new file mode 100644
index 0000000..b1684b7
--- /dev/null
+++ b/android/app/src/main/java/com/vortex/a3/service/ShareQueue.kt
@@ -0,0 +1,172 @@
+package com.vortex.a3.service
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import android.net.Uri
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import com.vortex.a3.R
+import com.vortex.a3.core.clipboard.ClipboardFileReader
+
+/**
+ * Paces a multi-file share instead of capping it.
+ *
+ * Sharing 150 files used to deliver about 20 and claim success for all of them:
+ * the outgoing bus dropped its overflow and the blob store evicted the rest
+ * before the laptop's one-at-a-time pull reached them. Capping the batch made
+ * that honest but refused work the user had asked for, which is worse UX than
+ * simply taking longer.
+ *
+ * So the whole list is accepted and fed through a window: at most
+ * [WINDOW] files are in flight, and the next is read only when one is
+ * confirmed delivered. Two properties fall out of that:
+ *
+ * * **Memory stays flat.** Files are read one at a time, on their turn — the
+ * queue holds URIs, not bytes. Reading 150 files up front is what makes an
+ * 850 MB share an OutOfMemoryError.
+ * * **Nothing is evicted unread.** In-flight never exceeds what the blob store
+ * holds, so a queued file's bytes are still there when its turn comes.
+ *
+ * Progress is a single updating notification rather than a toast per file —
+ * 150 toasts is its own bug.
+ */
+class ShareQueue(
+ private val context: Context,
+ /** Hand a read file to the existing offer path. Returns false if it could
+ * not be accepted, in which case the queue retries it later. */
+ private val emit: (com.vortex.a3.core.clipboard.ClipboardOutgoingFile) -> Boolean,
+ /** How many offers are awaiting collection right now. The window is
+ * measured against this, so pacing follows real delivery rather than a
+ * timer. */
+ private val inFlight: () -> Int,
+) {
+ private val pending = ArrayDeque()
+ private var total = 0
+ private var done = 0
+ private var failed = 0
+
+ /** Add [uris] to the queue and start (or continue) draining it. */
+ @Synchronized
+ fun enqueue(uris: List) {
+ if (uris.isEmpty()) return
+ pending.addAll(uris)
+ total += uris.size
+ Log.i(TAG, "queued ${uris.size} file(s); $total total, ${pending.size} waiting")
+ showProgress()
+ pump()
+ }
+
+ /** A file reached the laptop. Advance progress and start the next one. */
+ @Synchronized
+ fun noteServed(name: String) {
+ done++
+ Log.i(TAG, "delivered '$name' ($done/$total)")
+ showProgress()
+ pump()
+ }
+
+ /** Read and hand off files until the in-flight window is full. */
+ @Synchronized
+ fun pump() {
+ while (pending.isNotEmpty() && inFlight() < WINDOW) {
+ val uri = pending.removeFirst()
+ when (val outcome = ClipboardFileReader.read(context, uri)) {
+ is ClipboardFileReader.Outcome.Ok -> {
+ if (!emit(outcome.file)) {
+ // Downstream is momentarily full — put it back and stop;
+ // the next delivery re-enters here.
+ pending.addFirst(uri)
+ return
+ }
+ }
+ is ClipboardFileReader.Outcome.Unreadable -> {
+ failed++
+ Log.w(TAG, "skipping $uri: ${outcome.why}")
+ showProgress()
+ }
+ }
+ }
+ if (pending.isEmpty() && inFlight() == 0) finish()
+ }
+
+ private fun showProgress() {
+ // A single share of one file already gets the share-sheet toast; a
+ // progress notification on top of that is noise.
+ if (total <= 1) return
+ val nm = context.getSystemService(NotificationManager::class.java) ?: return
+ ensureChannel(nm)
+ val settled = done + failed
+ val n = NotificationCompat.Builder(context, CHANNEL_ID)
+ .setSmallIcon(R.drawable.vortex_logo)
+ .setContentTitle("Sending files to laptop")
+ .setContentText(
+ if (failed == 0) "$done of $total" else "$done of $total · $failed skipped",
+ )
+ .setProgress(total, settled, false)
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .build()
+ nm.notify(NOTIF_ID, n)
+ }
+
+ private fun finish() {
+ val nm = context.getSystemService(NotificationManager::class.java) ?: return
+ if (total <= 1) {
+ nm.cancel(NOTIF_ID)
+ reset()
+ return
+ }
+ ensureChannel(nm)
+ val n = NotificationCompat.Builder(context, CHANNEL_ID)
+ .setSmallIcon(R.drawable.vortex_logo)
+ .setContentTitle(
+ if (failed == 0) "Sent $done files" else "Sent $done of $total files",
+ )
+ .setContentText(
+ if (failed == 0) "All files reached the laptop"
+ else "$failed couldn't be sent (too large or unreadable)",
+ )
+ .setOngoing(false)
+ .setAutoCancel(true)
+ .build()
+ nm.notify(NOTIF_ID, n)
+ Log.i(TAG, "batch finished: $done sent, $failed skipped of $total")
+ reset()
+ }
+
+ private fun reset() {
+ total = 0
+ done = 0
+ failed = 0
+ }
+
+ private fun ensureChannel(nm: NotificationManager) {
+ if (nm.getNotificationChannel(CHANNEL_ID) != null) return
+ nm.createNotificationChannel(
+ NotificationChannel(
+ CHANNEL_ID,
+ "File transfers",
+ // LOW: a progress bar that pings on every one of 150 updates
+ // would be worse than the toasts it replaces.
+ NotificationManager.IMPORTANCE_LOW,
+ ).apply { description = "Progress while sending files to the laptop" },
+ )
+ }
+
+ companion object {
+ private const val TAG = "VortexShareQueue"
+ private const val CHANNEL_ID = "vortex_transfer"
+ private const val NOTIF_ID = 0x701E6
+
+ /**
+ * Files in flight at once.
+ *
+ * Must stay well under `ShareGrants.MAX_ENTRIES` so a queued
+ * file's bytes cannot be evicted before the laptop collects them, and
+ * small enough that the OFFER burst does not overrun the BLE notify
+ * path (the same reason the offer sender paces itself).
+ */
+ const val WINDOW = 8
+ }
+}
diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexService.kt b/android/app/src/main/java/com/vortex/a3/service/VortexService.kt
index f3a954f..65cdffa 100644
--- a/android/app/src/main/java/com/vortex/a3/service/VortexService.kt
+++ b/android/app/src/main/java/com/vortex/a3/service/VortexService.kt
@@ -208,6 +208,10 @@ class VortexService : Service() {
Log.i(tag, "onStartCommand flags=$flags startId=$startId action=${intent?.action}")
if (!stack.isStarted()) {
ensureStackStarted()
+ // A share can arrive before the stack is up (cold start from the
+ // share sheet). Queue it anyway — the queue paces itself off
+ // delivery, so it simply drains once the link exists.
+ if (intent?.action == ACTION_ENQUEUE_SHARE) enqueueShare(intent)
} else when (intent?.action) {
// READ_PHONE_STATE granted after the stack was already running
// (the common trusted-launch path never asked for it).
@@ -221,6 +225,7 @@ class VortexService : Service() {
// in-app button and the proximity auto-unlock.
ACTION_LOCK_LAPTOP -> requestLaptopLock(applicationContext, "lock")
ACTION_UNLOCK_LAPTOP -> requestLaptopLock(applicationContext, "unlock")
+ ACTION_ENQUEUE_SHARE -> enqueueShare(intent)
}
return START_STICKY
}
@@ -236,6 +241,30 @@ class VortexService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
+ /**
+ * Take the URIs a share handed us and queue them.
+ *
+ * The URIs arrive in the Intent's ClipData with
+ * `FLAG_GRANT_READ_URI_PERMISSION`, which is what extends the share sheet's
+ * read grant to this service. That indirection is the point: the queue
+ * reads each file on its turn rather than the Activity reading all of them
+ * up front, so memory stays flat no matter how many were selected.
+ */
+ private fun enqueueShare(intent: Intent) {
+ val clip = intent.clipData
+ val uris = buildList {
+ if (clip != null) {
+ for (i in 0 until clip.itemCount) clip.getItemAt(i).uri?.let { add(it) }
+ }
+ }
+ if (uris.isEmpty()) {
+ Log.w(tag, "enqueueShare: no URIs in ClipData")
+ return
+ }
+ Log.i(tag, "enqueueShare: ${uris.size} file(s)")
+ stack.shareQueue.enqueue(uris)
+ }
+
private val retryHandler = android.os.Handler(android.os.Looper.getMainLooper())
private val retryStart = Runnable { ensureStackStarted() }
@@ -288,6 +317,9 @@ class VortexService : Service() {
* it. One-tap (no biometric); the laptop gates on this phone being
* unlocked (owner-present gate). */
const val ACTION_UNLOCK_LAPTOP = "com.vortex.a3.UNLOCK_LAPTOP"
+ /** Intent action: a share sheet handed us files to send. URIs ride in
+ * ClipData with FLAG_GRANT_READ_URI_PERMISSION. */
+ const val ACTION_ENQUEUE_SHARE = "com.vortex.a3.ENQUEUE_SHARE"
/**
* Latest peer AppState snapshot, keyed by peer_static_pub hex.
@@ -341,6 +373,20 @@ class VortexService : Service() {
/** A FILE (any non-image content) captured on THIS phone for sending to
* the laptop — same offer+LAN-pull path as images, but the laptop
* writes it to disk and makes it pasteable. */
+ /**
+ * Outgoing shared files. Buffer holds a whole capped batch, and
+ * overflow SUSPENDS rather than dropping.
+ *
+ * It was 4 slots with DROP_OLDEST, which silently discarded most of any
+ * multi-file share: sharing 150 files delivered about 20, because the
+ * collector (stash + JSON + BLE notify) could not drain a 4-slot buffer
+ * as fast as the share loop filled it, and DROP_OLDEST throws away the
+ * overflow without telling anyone. Worse, `tryEmit` returns TRUE on a
+ * drop, so the sender counted every file as sent and the toast lied.
+ *
+ * With SUSPEND, `tryEmit` returns false instead of discarding, so the
+ * caller can count what was actually accepted and report the rest.
+ */
val clipboardFileBus: kotlinx.coroutines.flow.MutableSharedFlow<
com.vortex.a3.core.clipboard.ClipboardOutgoingFile> =
kotlinx.coroutines.flow.MutableSharedFlow(
@@ -353,7 +399,13 @@ class VortexService : Service() {
// toast still said "Sending 10 files to laptop…". Dropping the
// user's files is not a reasonable answer to back-pressure, and
// a queue of offers costs almost nothing to hold.
- extraBufferCapacity = 64,
+ //
+ // Bound to the blob store rather than a bare number: a file
+ // emitted onto this bus but already evicted from the store
+ // cannot be served anyway, so more slots than the store holds
+ // would only queue offers that are certain to fail.
+ extraBufferCapacity =
+ com.vortex.a3.core.fs.ShareGrants.MAX_ENTRIES,
onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.SUSPEND,
)
@@ -634,6 +686,30 @@ class VortexService : Service() {
liveLan?.nudge()
}
+ /**
+ * "Switch laptop": start advertising to the OTHER remembered laptops
+ * while staying connected to the current one.
+ *
+ * Seek-before-release (design doc §D3): nothing is dropped here. The
+ * current link is held until another laptop actually connects, so a
+ * cancelled or fruitless seek leaves the phone exactly where it was.
+ *
+ * Returns false when there is no stack running or fewer than two
+ * remembered laptops, so the UI can leave the button disabled rather
+ * than opening a window that cannot succeed.
+ */
+ fun startSeeking(target: ByteArray? = null): Boolean =
+ liveStack?.startSeeking(target) ?: false
+
+ /** Close a seek window; advertising returns to whatever the phase
+ * machine says (silent while linked, presence otherwise). */
+ fun stopSeeking() {
+ liveStack?.stopSeeking()
+ }
+
+ /** True while a seek window is open — drives the UI's spinner. */
+ fun isSeeking(): Boolean = liveStack?.isSeeking() ?: false
+
/** Public entrypoint: start the service from anywhere. Idempotent. */
fun start(context: Context) {
val intent = Intent(context, VortexService::class.java)
diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt b/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt
index 2f67eda..2510504 100644
--- a/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt
+++ b/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt
@@ -5,6 +5,7 @@ import android.content.Context
import android.os.Build
import android.util.Log
import com.vortex.a3.core.ble.Advertiser
+import com.vortex.a3.core.ble.FrameSub
import com.vortex.a3.core.ble.GattServer
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager
@@ -67,7 +68,48 @@ class VortexStack(internal val service: Service) : VortexNotification.Host {
)
private var advertiser: Advertiser? = null
+
+ /**
+ * static_pub of the laptop we are currently linked to, learned from the
+ * IK handshake (the only thing that proves *which* peer a link belongs
+ * to — a BLE address alone does not).
+ *
+ * Used by the presence loop to exclude the current laptop while seeking
+ * for a different one. Deliberately NOT cleared on disconnect: after a
+ * drop it is still the peer we were last with, which is what the seek
+ * filter wants, and a stale value is harmless because seeking only ever
+ * removes one candidate from the list.
+ */
+ @Volatile internal var activePeerPub: ByteArray? = null
+
+ /**
+ * When the owner was last heard from, on ANY transport.
+ *
+ * Liveness is a timestamp rather than a disconnect event because the two
+ * transports fail differently: a BLE link raises a disconnect, while a LAN
+ * session is torn down and rebuilt every heartbeat by design, so "the
+ * socket closed" says nothing at all there. Last contact is the one signal
+ * that means the same thing on both.
+ *
+ * Ownership then outlives a blip: it becomes available to another laptop
+ * only after [OWNERSHIP_GRACE_MS] of silence, which is the walk-away case
+ * rather than the flap.
+ */
+ @Volatile internal var activeSeenAtMs: Long = 0L
internal var gattServer: GattServer? = null
+ /** Open read handles held for the current peer's filesystem session, so
+ * they can be dropped when the link goes. Null until [startFsServer]. */
+ internal var fsHandles: com.vortex.a3.core.fs.FsHandles? = null
+
+ /** The filesystem serve function, kept here because the two transports are
+ * started at different times: BLE comes up before the LAN server exists,
+ * and `restartBleComponents` replaces the server (and its handle table)
+ * without touching the LAN side. Whoever starts second installs it. */
+ internal var fsServeFn: ((Byte, ByteArray) -> Pair)? = null
+
+ /** Route for FS replies arriving over LAN. Same late-binding problem as
+ * [fsServeFn]: the LAN server does not exist yet when BLE starts. */
+ internal var fsReplyFn: ((Byte, ByteArray) -> Unit)? = null
/** Buffers phone→laptop notifications that fail to send while BLE is down;
* flushed when the peer re-subscribes to AUDIO_SIGNAL. */
internal val notificationOutbox = com.vortex.a3.core.notif.NotificationOutbox()
@@ -77,6 +119,66 @@ class VortexStack(internal val service: Service) : VortexNotification.Host {
internal val sentIconPkgs = java.util.Collections.synchronizedSet(HashSet())
private var clipboardListener: com.vortex.a3.core.clipboard.ClipboardListener? = null
internal var wifiDirectTeardownJob: kotlinx.coroutines.Job? = null
+ /** elapsedRealtime of the last WIFI_DIRECT_OFFER, for coalescing (see
+ * [maybeStartWifiDirect]). 0 = none since the group last went down. */
+ @Volatile internal var lastWifiDirectOfferAtMs: Long = 0L
+
+ /**
+ * The one laptop a seek is aimed at, when the user picked it explicitly.
+ *
+ * A targeted seek advertises only THAT peer's token instead of cycling all
+ * of them, so it is both faster to be found (no dwell sharing) and cheaper
+ * on air. `null` = untargeted, i.e. "any remembered laptop but the current
+ * one" (design doc §D1).
+ */
+ @Volatile internal var seekTarget: ByteArray? = null
+
+ /**
+ * Does [peerPub] own this phone's session — and take it if it may?
+ *
+ * CONNECTED IS NOT ACTIVE (design doc §D4). The laptop side has always kept
+ * those apart; this side did not, and every path that heard from a laptop
+ * simply treated it as the current one. With two laptops up, that made the
+ * phone flip between them: the BLE loop promoted whoever completed IK last,
+ * and [handlePeerAppState] overwrote the UI's single peer slot from
+ * whichever LAN heartbeat landed most recently — about one every twelve
+ * seconds, from each.
+ *
+ * Ownership moves only when:
+ * - nothing holds it;
+ * - the holder is the one calling (it refreshes its own claim);
+ * - the user picked this laptop in the UI (a targeted seek), or an
+ * untargeted seek is running and this is the first to answer;
+ * - the holder has been silent for [OWNERSHIP_GRACE_MS].
+ *
+ * Everything else may hold a link, sync files, and get no ownership with
+ * it. Returns true when the caller owns the session after this call.
+ */
+ internal fun considerOwnership(peerPub: ByteArray): Boolean {
+ val now = android.os.SystemClock.elapsedRealtime()
+ val current = activePeerPub
+ if (current == null || current.contentEquals(peerPub)) {
+ activePeerPub = peerPub.copyOf()
+ activeSeenAtMs = now
+ return true
+ }
+ val chosen = seekTarget?.contentEquals(peerPub) == true
+ // An untargeted seek ("switch to any other laptop") names no
+ // destination, so the first to answer IS the choice.
+ val anySeek = advertiser?.seeking == true && seekTarget == null
+ val ownerGone = activeSeenAtMs != 0L && now - activeSeenAtMs > OWNERSHIP_GRACE_MS
+ if (chosen || anySeek || ownerGone) {
+ Log.i(
+ TAG,
+ "session ownership → ${peerPub.toHexPrefix()} " +
+ "(chosen=$chosen seek=$anySeek ownerSilent=$ownerGone)",
+ )
+ activePeerPub = peerPub.copyOf()
+ activeSeenAtMs = now
+ return true
+ }
+ return false
+ }
/** Icon PNG bytes per ICON frame chunk (kept under the BLE notify MTU
* once the appId header + AEAD tag + frame header are added). */
internal val ICON_CHUNK = 180
@@ -92,6 +194,22 @@ class VortexStack(internal val service: Service) : VortexNotification.Host {
* cancelled on disconnect — so only a session stable for
* [MIRROR_REFRESH_SETTLE_MS] pays the ~37-chunk BLE storm. */
private var mirrorRefreshJob: kotlinx.coroutines.Job? = null
+ /** Bounded "switch laptop" window (design doc §D9). Seeking while a
+ * session is live is the most expensive radio state there is, so an
+ * unattended press must not scan forever. */
+ private var seekJob: kotlinx.coroutines.Job? = null
+
+ /** Paces multi-file shares (see [ShareQueue]). Lazy because it needs
+ * `ctx`, which resolves through the Service's base context. */
+ internal val shareQueue: ShareQueue by lazy {
+ ShareQueue(
+ context = ctx,
+ emit = { file -> VortexService.clipboardFileBus.tryEmit(file) },
+ // Offers awaiting collection == files in flight, so pacing follows
+ // real delivery instead of a timer.
+ inFlight = { pendingOffers.size },
+ )
+ }
internal var contactsProvider: com.vortex.a3.core.contacts.ContactsProvider? = null
/** Reads the phone's recent call log + observes changes; emits to callLogBus. */
@@ -473,6 +591,7 @@ class VortexStack(internal val service: Service) : VortexNotification.Host {
}
gattServer = server
startNotesSync() // notes/todos bidirectional sync (NOTES_SYNC)
+ startFsServer() // serve shared folders to the laptop (FS_REQ)
// BLE-WRITE reverse channel: when the laptop AEAD-seals an
// AudioOpFrame and WRITEs it to AUDIO_SIGNAL, the GattServer decrypts
@@ -647,9 +766,17 @@ class VortexStack(internal val service: Service) : VortexNotification.Host {
// BLE session gone → mDNS discovery matters again; re-hold the
// multicast lock so the laptop can find us over LAN. Also drop any
// pending mirror burst — a flapping session must not queue storms.
- server.onPeerDisconnected = { _ ->
+ server.onPeerDisconnected = { device ->
+ // Deliberately does NOT touch ownership. A dropped BLE link is not
+ // evidence the laptop has gone — it may still be right there on
+ // Wi-Fi — so the owner is timed out on silence instead
+ // ([activeSeenAtMs]), which is the one signal both transports share.
mirrorRefreshJob?.cancel()
lanServer?.setBleLinked(false)
+ // Handles belong to the session that opened them: the ids mean
+ // nothing to a new one, and waiting for the 5-minute idle sweep
+ // would hold a descriptor per file the laptop was mid-copy on.
+ stopFsServer()
// Engage the reconnect-seeking LOW_LATENCY advertising NOW —
// waiting for the next 60s rotation cost the whole first
// reconnect window after a walk-away.
@@ -730,6 +857,36 @@ class VortexStack(internal val service: Service) : VortexNotification.Host {
outcome.ciphers.receiver,
)
val peerPub = outcome.peerStaticPub.copyOf()
+ val previousPeer = activePeerPub
+ val seeking = advertiser?.seeking == true
+ val sameAsActive = previousPeer?.contentEquals(peerPub) == true
+ val takeOver = considerOwnership(peerPub)
+ if (!takeOver) {
+ Log.i(TAG, "${peerPub.toHexPrefix()} linked but NOT active — another laptop owns the session")
+ }
+ // A DIFFERENT laptop just completed IK while we were seeking —
+ // that is the switch succeeding, so close the window. Ownership
+ // has effectively moved; the old link drops on its own (the
+ // laptop side stops being active the moment it hands over).
+ if (seeking && takeOver && !sameAsActive) {
+ Log.i(TAG, "seek satisfied — another laptop connected")
+ // Tell the laptop we just left, while its link is still up.
+ // Ordering matters: this runs BEFORE stopSeeking() teardown so
+ // the old session is still registered and can carry the frame.
+ // Best-effort — if it has already dropped, that laptop falls
+ // back to noticing on next contact, which is what happened
+ // before this frame existed.
+ previousPeer?.let { old ->
+ val successor = try {
+ peerStore.load(peerPub)?.peerName.orEmpty()
+ } catch (_: Exception) { "" }
+ val sent = server.sendPeerHandoffEncrypted(
+ old, FrameSub.HANDOFF_RELEASE, successor,
+ )
+ Log.i(TAG, "RELEASE to previous laptop sent=$sent")
+ }
+ stopSeeking()
+ }
val bleWriter: suspend (com.vortex.a3.core.earbuds.AudioOpFrame) -> Result = { f ->
val ok = server.sendAudioOpEncrypted(peerPub, f.toJsonBytes())
if (ok) Result.success(Unit)
@@ -742,8 +899,18 @@ class VortexStack(internal val service: Service) : VortexNotification.Host {
// known snapshot) instead of waiting ~1.5s for the laptop's
// first state heartbeat. Without this the laptop UI flips to
// "connected" visibly earlier than the phone after a reconnect.
- latestPeerState?.let { st ->
- VortexService.peerStateBus.tryEmit(peerPub.toHex() to st)
+ //
+ // Owner only, for two reasons. The home screen picks the laptop to
+ // show by FRESHEST traffic, so nudging a laptop that does not own
+ // the session would put it straight back at the top of that
+ // ordering — the dance, re-entered by another door. And
+ // `latestPeerState` is the OWNER's snapshot: attributing it to a
+ // different laptop would draw that one's card with someone else's
+ // battery.
+ if (takeOver) {
+ latestPeerState?.let { st ->
+ VortexService.peerStateBus.tryEmit(peerPub.toHex() to st)
+ }
}
}
@@ -762,16 +929,84 @@ class VortexStack(internal val service: Service) : VortexNotification.Host {
android.os.SystemClock.elapsedRealtime() - srv.lastDisconnectAtMs <
FAST_ADV_WINDOW_MS
}
- val firstPeer = peerStore.list().firstOrNull()
- if (firstPeer != null) {
- adv.startTrustedPresence(
- prs = firstPeer.prs,
+ // The laptop handed ownership to another phone (design doc §D4). Stop
+ // presenting ourselves as its active peer and go back on air so a
+ // laptop that DOES want us can find us — otherwise we would sit
+ // silently attached to a laptop that has moved on, invisible to
+ // everything else until the link happened to drop.
+ server.onPeerHandoffReceived = { peerPub, kind, successorName ->
+ if (kind == FrameSub.HANDOFF_RELEASE) {
+ val who = if (successorName.isBlank()) "another phone" else successorName
+ Log.i(TAG, "peer released us (now with $who) — resuming presence")
+ if (activePeerPub?.contentEquals(peerPub) == true) {
+ activePeerPub = null
+ activeSeenAtMs = 0L
+ }
+ // A seek in flight is moot: the laptop already chose someone.
+ stopSeeking()
+ // Presence resumes on the next phase evaluation; kick it so we
+ // are discoverable now rather than up to ACTIVE_RECHECK_MS later.
+ advertiser?.kickRotation()
+ } else {
+ // BUSY / CLAIM are defined in the contract but nothing sends
+ // them yet; log so an unexpected one is visible rather than
+ // silently dropped.
+ Log.i(TAG, "PEER_HANDOFF kind=0x${"%02x".format(kind)} — no handler")
+ }
+ }
+
+ // Advertising is suspended while a session is live — the session IS the
+ // presence proof, and beaconing on top of it is the single largest
+ // avoidable battery cost here (design doc §D5).
+ adv.linkedProvider = provider@{
+ val srv = gattServer ?: return@provider false
+ // SUBSCRIBED, not merely ACL-connected. BlueZ owns the ACL link, so
+ // it outlives the laptop app: after a restart the phone saw a
+ // "connection" with no session behind it, stayed silent, and became
+ // unreachable — the laptop had nothing to find and the phone had no
+ // reason to advertise. Observed live: file offers sat retrying with
+ // "BLE link down?" while the phone never advertised.
+ srv.hasAudioSignalSubscriber()
+ }
+ // Which peers' tokens we may advertise. Re-read every round rather
+ // than captured once, so pairing a new laptop or forgetting one takes
+ // effect without restarting the loop.
+ //
+ // While SEEKING we exclude the peer we are currently linked to: the
+ // user pressed Switch precisely because they want a different laptop,
+ // and spending dwell slots on the current one would only slow the
+ // others down.
+ adv.presencePeersProvider = provider@{
+ val all = try { peerStore.list() } catch (e: Exception) {
+ Log.w(TAG, "presence peers: peer store unavailable: ${e.message}")
+ return@provider emptyList()
+ }
+ if (!adv.seeking) return@provider all.map { it.prs }
+ // Targeted seek: advertise only the chosen laptop's token. One
+ // token means no dwell sharing, so it is seen as fast as the
+ // single-peer case.
+ seekTarget?.let { target ->
+ return@provider all.filter { it.peerStaticPub.contentEquals(target) }
+ .map { it.prs }
+ }
+ // Suppress a token only for a peer holding a LIVE GATT link: that
+ // session IS the presence proof, so beaconing at it is waste.
+ //
+ // Keyed on the link, NOT on ownership. They are no longer the same
+ // thing — a laptop can own the session over Wi-Fi with no BLE link
+ // at all, and suppressing its token then would leave it unable to
+ // find us on the one transport that still works.
+ val linked = server.linkedPeerPubs()
+ all.filter { p -> linked.none { it.contentEquals(p.peerStaticPub) } }
+ .map { it.prs }
+ }
+ if (peerStore.list().isNotEmpty()) {
+ adv.startPresenceLoop(
scope = scope,
rotationWindowSec = 60L,
- isConnected = { gattServer?.hasActiveConnection() == true },
onError = { reason -> Log.w(TAG, "presence adv error: $reason") },
)
- Log.i(TAG, "trusted-presence advertising started (have ${peerStore.list().size} peer(s))")
+ Log.i(TAG, "presence loop started (have ${peerStore.list().size} peer(s))")
} else {
Log.i(TAG, "no trust — service idle, awaiting pairing")
}
@@ -779,6 +1014,53 @@ class VortexStack(internal val service: Service) : VortexNotification.Host {
return true
}
+ /**
+ * Open a seek window: advertise to the other remembered laptops while
+ * staying connected to the current one. See [VortexService.startSeeking].
+ */
+ fun startSeeking(target: ByteArray? = null): Boolean {
+ val adv = advertiser ?: return false
+ // Nothing to switch to — refuse rather than burn the radio on a window
+ // that cannot possibly succeed.
+ val peerCount = try { peerStore.list().size } catch (_: Exception) { 0 }
+ if (peerCount < 2) {
+ Log.i(TAG, "seek refused: only $peerCount remembered laptop(s)")
+ return false
+ }
+ seekJob?.cancel()
+ seekTarget = target?.copyOf()
+ adv.seeking = true
+ // Re-advertise immediately with the seek peer set instead of waiting
+ // out the current dwell / rotation sleep.
+ adv.kickRotation()
+ Log.i(TAG, "seek window opened ($peerCount peers, excluding the current one)")
+ seekJob = scope.launch {
+ kotlinx.coroutines.delay(SEEK_WINDOW_MS)
+ // Timed out with nobody else picking us up. Close quietly: the
+ // current laptop was never dropped, so there is nothing to undo.
+ if (adv.seeking) {
+ Log.i(TAG, "seek window expired")
+ stopSeeking()
+ }
+ }
+ return true
+ }
+
+ fun stopSeeking() {
+ seekJob?.cancel()
+ seekJob = null
+ val adv = advertiser ?: return
+ if (!adv.seeking) return
+ adv.seeking = false
+ seekTarget = null
+ // Back to the phase machine's verdict: silent if still linked,
+ // presence otherwise.
+ adv.kickRotation()
+ Log.i(TAG, "seek window closed")
+ }
+
+ fun isSeeking(): Boolean = advertiser?.seeking == true
+
/**
* Re-create the BLE stack after the BT adapter has come back ON. Tears
* down the now-invalid advertiser/GATT handles first then rebuilds. LAN
@@ -997,6 +1279,10 @@ class VortexStack(internal val service: Service) : VortexNotification.Host {
// measured at five seconds on the first real attempt. The BLE write is
// ours to make and lands in a couple of hundred milliseconds.
VortexService.appStateNudge = { pushStateViaBle() }
+ // BLE started first, so the serve function already exists; install it
+ // now that there is a LAN server to hang it on.
+ lan.fsServe = fsServeFn
+ lan.onFsReply = fsReplyFn
}
/**
@@ -1072,10 +1358,34 @@ class VortexStack(internal val service: Service) : VortexNotification.Host {
companion object {
internal const val TAG = "VortexStack"
+
+ /**
+ * How long a disconnected owner keeps the session before another
+ * laptop may take it.
+ *
+ * Long enough to ride out a BLE flap — which is the difference between
+ * "my laptop blinked" and "I walked away" — and short enough that
+ * arriving at the other desk does not feel stuck. Tapping the device
+ * in the UI bypasses the wait entirely, because an explicit choice
+ * should never queue behind a timer.
+ */
+ internal const val OWNERSHIP_GRACE_MS = 20_000L
/** How long after losing the laptop link the phone keeps
* advertising in LOW_LATENCY (reconnect-seeking) mode. */
internal const val FAST_ADV_WINDOW_MS = 10 * 60_000L
+ /** How long a "switch laptop" seek window stays open. Matches the
+ * laptop's SWITCH_WINDOW_SECS so both ends give up together —
+ * long enough to walk to another machine and wake it, short
+ * enough that an unattended press stops advertising-on-top-of-a-
+ * live-link before it costs real battery. */
+ internal const val SEEK_WINDOW_MS = 45_000L
+
+ /** Minimum gap between WIFI_DIRECT_OFFERs. Each one costs the laptop a
+ * Wi-Fi disconnect/reconnect (single adapter), so they must not track
+ * the file count. Well inside the 60 s idle teardown. */
+ internal const val WIFI_DIRECT_OFFER_MIN_GAP_MS = 30_000L
+
/** How long an AUDIO_SIGNAL subscription must stay up before the
* companion mirror burst (contacts/recents/SMS, ~37 chunks) fires.
* Guards the desync feedback loop — see onAudioSignalSubscribed. */
diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexStackAppState.kt b/android/app/src/main/java/com/vortex/a3/service/VortexStackAppState.kt
index 49a264a..72861d0 100644
--- a/android/app/src/main/java/com/vortex/a3/service/VortexStackAppState.kt
+++ b/android/app/src/main/java/com/vortex/a3/service/VortexStackAppState.kt
@@ -139,6 +139,26 @@ private fun currentWifiIp(): String? = try {
* notification, honour a bidirectional forget, run the initiator on a
* claim request, and release the buds when the laptop starts playing. */
internal fun VortexStack.handlePeerAppState(peerPub: ByteArray, state: com.vortex.a3.core.appstate.AppState) {
+ // Being dropped is honoured from ANY laptop — revoking trust is not an
+ // ownership-gated act, and a laptop that has just forgotten us has nothing
+ // else worth saying.
+ if (state.revoked) {
+ Log.i(VortexStack.TAG, "peer revoked us; forgetting ${peerPub.toHexPrefix()}")
+ peerStore.forget(peerPub)
+ VortexService.revokedByPeerBus.tryEmit(peerPub.toHex())
+ return
+ }
+ // Everything below belongs to whichever laptop OWNS the session: the card
+ // the UI draws, the media hand-off, the camera, the cast, the ring.
+ //
+ // This is where the peer dance actually lived. `latestPeerState` is a
+ // single slot, and every laptop's heartbeat overwrote it — roughly one
+ // every twelve seconds, from each — so with two laptops up the phone's
+ // idea of "the laptop" alternated between them no matter what the BLE
+ // ownership rules said. The earlier fix guarded the BLE path alone; the
+ // observed flapping was pure LAN and never went near it.
+ if (!considerOwnership(peerPub)) return
+
VortexService.peerStateBus.tryEmit(peerPub.toHex() to state)
latestPeerState = state
latestPeerStateAtMs = android.os.SystemClock.elapsedRealtime()
@@ -209,12 +229,6 @@ internal fun VortexStack.handlePeerAppState(peerPub: ByteArray, state: com.vorte
state.dnd,
state.dndChangedAt,
)
- // Bidirectional forget — peer asked us to drop their trust.
- if (state.revoked) {
- Log.i(VortexStack.TAG, "peer revoked us; forgetting ${peerPub.toHexPrefix()}")
- peerStore.forget(peerPub)
- VortexService.revokedByPeerBus.tryEmit(peerPub.toHex())
- }
// Peer holds the buds and is asking us to claim them. We become the
// initiator. The orchestrator drops the request if a flow is already
// in progress, so this is idempotent across repeated heartbeats —
diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexStackClipboard.kt b/android/app/src/main/java/com/vortex/a3/service/VortexStackClipboard.kt
index 01c8ae0..0518b27 100644
--- a/android/app/src/main/java/com/vortex/a3/service/VortexStackClipboard.kt
+++ b/android/app/src/main/java/com/vortex/a3/service/VortexStackClipboard.kt
@@ -84,15 +84,22 @@ internal fun VortexStack.startClipboardOutbound() {
scope.launch {
VortexService.clipboardFileBus.collect { file ->
if (!com.vortex.a3.core.clipboard.ClipboardSyncSetting.isEnabled()) return@collect
- if (file.bytes.isEmpty()) return@collect
- val token = com.vortex.a3.core.clipboard.ClipboardBlobStore.stash(file.bytes)
+ // A grant, not the bytes: the laptop pulls the file in ranges
+ // through the filesystem protocol, so nothing is buffered here and
+ // the old 64 MB cap is gone with it.
+ val token = com.vortex.a3.core.fs.ShareGrants.grant(
+ file.uri,
+ file.name,
+ file.mime,
+ file.size,
+ )
val o = org.json.JSONObject()
o.put("token", token)
- o.put("bytes", file.bytes.size)
+ o.put("bytes", file.size)
o.put("name", file.name)
o.put("mime", file.mime)
val offer = o.toString().toByteArray(Charsets.UTF_8)
- Log.i(VortexStack.TAG, "clipboard file offered to laptop ('${file.name}', ${file.bytes.size} bytes, token=$token)")
+ Log.i(VortexStack.TAG, "clipboard file offered to laptop ('${file.name}', ${file.size} bytes, token=$token)")
// Tracked until the laptop has actually FETCHED the bytes: the OFFER
// is a fire-and-forget BLE notify that goes nowhere on a dead link,
// and even a delivered one can sit unfetched. Retries, warms the LAN
@@ -100,7 +107,7 @@ internal fun VortexStack.startClipboardOutbound() {
offerFileToLaptop(token, file.name, offer)
// Big file → bring up Wi-Fi Direct for a high-speed direct pull. Small
// files stay on the router path (the ~6s Wi-Fi switch isn't worth it).
- if (file.bytes.size >= 4 * 1024 * 1024) maybeStartWifiDirect()
+ if (file.size >= 4 * 1024 * 1024) maybeStartWifiDirect()
}
}
}
@@ -130,18 +137,25 @@ internal fun VortexStack.startClipboardOutbound() {
*/
internal fun VortexStack.offerCapturedMedia(media: com.vortex.a3.core.media.CapturedMedia) {
scope.launch {
- val file = com.vortex.a3.core.clipboard.ClipboardFileReader.read(ctx, media.uri)
+ val file = com.vortex.a3.core.clipboard.ClipboardFileReader.readOrNull(ctx, media.uri)
if (file == null) {
Log.w(VortexStack.TAG, "${media.kind.name.lowercase()} _id=${media.id} unreadable or over the cap; not sent")
return@launch
}
val name = media.name.ifBlank { file.name }
- val token = com.vortex.a3.core.clipboard.ClipboardBlobStore.stashLazy(file.bytes) {
- com.vortex.a3.core.clipboard.ClipboardFileReader.read(ctx, media.uri)?.bytes
- }
+ // A grant, not the bytes — same as the share path above. A captured
+ // video is routinely hundreds of megabytes, and stashing one in the
+ // blob store is the allocation this branch exists to remove: the
+ // laptop pulls it in ranges through the filesystem protocol instead.
+ val token = com.vortex.a3.core.fs.ShareGrants.grant(
+ file.uri,
+ name,
+ file.mime,
+ file.size,
+ )
val o = org.json.JSONObject()
o.put("token", token)
- o.put("bytes", file.bytes.size)
+ o.put("bytes", file.size)
o.put("name", name)
o.put("mime", file.mime)
// Tells the laptop which subfolder and which notification, and that
@@ -152,7 +166,7 @@ internal fun VortexStack.offerCapturedMedia(media: com.vortex.a3.core.media.Capt
// passed on (see CaptureLedger). Recorded at the offer, not at the
// fetch: the laptop files its copy under the same token either way.
com.vortex.a3.core.media.CaptureLedger.record(token, media.collection, media.uri)
- Log.i(VortexStack.TAG, "${media.kind.name.lowercase()} offered to laptop ('$name', ${file.bytes.size} bytes, token=$token)")
+ Log.i(VortexStack.TAG, "${media.kind.name.lowercase()} offered to laptop ('$name', ${file.size} bytes, token=$token)")
offerFileToLaptop(token, name, offer, quiet = true)
}
}
diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexStackFs.kt b/android/app/src/main/java/com/vortex/a3/service/VortexStackFs.kt
new file mode 100644
index 0000000..20c6683
--- /dev/null
+++ b/android/app/src/main/java/com/vortex/a3/service/VortexStackFs.kt
@@ -0,0 +1,120 @@
+package com.vortex.a3.service
+
+import com.vortex.a3.core.ble.FrameType
+import com.vortex.a3.core.fs.FsHandles
+import com.vortex.a3.core.fs.FsRoots
+import com.vortex.a3.core.fs.FsServer
+import com.vortex.a3.core.fs.FsCode
+import com.vortex.a3.core.fs.FsErr
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+/**
+ * Wires filesystem serving (FS_REQ 0x50 → FS_META / FS_DATA / FS_ERR) into the
+ * BLE stack: the phone half of `docs/design/file-browsing.md`, answering the
+ * laptop's ranged reads against the folders the user has shared.
+ *
+ * Serving is READ-ONLY in v1. Writes are refused explicitly rather than
+ * dropped — see [FsServer].
+ *
+ * Extension fn on [VortexStack]; call once after the GATT server is up.
+ */
+internal fun VortexStack.startFsServer() {
+ val roots = FsRoots(ctx)
+ val handles = FsHandles()
+ val server = FsServer(ctx, roots, handles)
+ fsHandles = handles
+ // A share-sheet file the laptop just finished reading. Same completion
+ // signal the old bulk-sync path got from writing the whole blob onto the
+ // socket: advances the batch's progress and releases the next queued file.
+ server.onShareDelivered = { token -> noteFileServed(token) }
+
+ // Wi-Fi path. The laptop prefers it and falls back to BLE, so BOTH
+ // transports serve from this one server and one handle table: a handle is
+ // minted by OPEN and used by later READs, and a fallback between the two
+ // would otherwise answer BADF halfway through a file.
+ //
+ // Runs on the LanServer connection's own thread rather than being hopped
+ // onto Dispatchers.IO: that thread exists to serialise this socket's
+ // frames, and the reply must be written before the next op is read.
+ val serve: (Byte, ByteArray) -> Pair = { op, payload ->
+ val srv = try {
+ server.serve(op, payload)
+ } catch (e: Exception) {
+ FsServer.Served.Err(FsErr(0, FsCode.IO, e.message ?: "serve failed"))
+ }
+ when (srv) {
+ is FsServer.Served.Meta -> FrameType.FS_META to srv.reply.toJsonBytes()
+ is FsServer.Served.Data -> FrameType.FS_DATA to srv.bytes
+ is FsServer.Served.Err -> FrameType.FS_ERR to srv.err.toJsonBytes()
+ }
+ }
+ fsServeFn = serve
+ // Null on first start (BLE comes up before the LAN server, which installs
+ // it itself); non-null on a BLE restart, which replaces the server and
+ // handle table the LAN side was still pointing at.
+ lanServer?.fsServe = serve
+
+ // The other direction: browsing the LAPTOP's files from this phone. Same
+ // ops, same frames — the protocol is symmetric — so the client needs only a
+ // way to send and a way to be handed replies.
+ com.vortex.a3.core.fs.FsClient.sender = { op, payload ->
+ // Wi-Fi first, exactly as the laptop does for the same frames: a 48 KiB
+ // read is one TCP frame against ~96 paced BLE fragments.
+ //
+ // The first request of a browse still goes over BLE, and cannot not:
+ // the phone has no way to dial the laptop, which runs no listener. What
+ // it can do is answer on the session the laptop opens to deliver its
+ // reply — that socket is bidirectional and the laptop serves whatever
+ // arrives on it — so BLE carries the opening request and Wi-Fi carries
+ // the rest, including every ranged read of a download.
+ lanServer?.fsSend(op, payload) == true || run {
+ val peer = activePeerPub ?: peerStore.list().firstOrNull()?.peerStaticPub
+ peer != null && gattServer?.sendFsRequest(peer, op, payload) == true
+ }
+ }
+ gattServer?.onFsReply = { _, type, payload ->
+ com.vortex.a3.core.fs.FsClient.onReply(type, payload)
+ }
+ // The same replies can arrive over Wi-Fi instead: the laptop's client picks
+ // its transport per send, so the one that carried our request is not
+ // necessarily the one that answers it.
+ lanServer?.onFsReply = { type, payload ->
+ com.vortex.a3.core.fs.FsClient.onReply(type, payload)
+ }
+ fsReplyFn = { type, payload -> com.vortex.a3.core.fs.FsClient.onReply(type, payload) }
+
+ gattServer?.onFsRequest = { peerPub, op, payload ->
+ // Off the GATT callback thread, always. A document provider can stall
+ // for seconds — a cloud-backed one indefinitely — and blocking here
+ // would stall every other frame on the link behind one slow folder,
+ // including the audio-switch path that shares this characteristic.
+ scope.launch(Dispatchers.IO) {
+ val srv = try {
+ server.serve(op, payload)
+ } catch (e: Exception) {
+ // Never let an unexpected provider exception become silence:
+ // the laptop is blocked on this request id and would wait out
+ // its timeout instead of showing an error.
+ FsServer.Served.Err(FsErr(0, FsCode.IO, e.message ?: "serve failed"))
+ }
+ val (type, bytes) = when (srv) {
+ is FsServer.Served.Meta -> FrameType.FS_META to srv.reply.toJsonBytes()
+ is FsServer.Served.Data -> FrameType.FS_DATA to srv.bytes
+ is FsServer.Served.Err -> FrameType.FS_ERR to srv.err.toJsonBytes()
+ }
+ gattServer?.sendFsReply(peerPub, type, bytes)
+ }
+ }
+}
+
+/** Drop every open handle for a link that has gone. Handles cannot outlive the
+ * session that owns them: the ids are only meaningful to that peer, and an
+ * abandoned descriptor is a leak the idle sweep would take five minutes to
+ * notice. */
+internal fun VortexStack.stopFsServer() {
+ fsHandles?.clear()
+ // Nothing in flight can be answered once the link is gone; fail the waiters
+ // now rather than leaving the UI parked until each one times out.
+ com.vortex.a3.core.fs.FsClient.reset()
+}
diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexStackOfferRetry.kt b/android/app/src/main/java/com/vortex/a3/service/VortexStackOfferRetry.kt
index 5374cb0..2867b6c 100644
--- a/android/app/src/main/java/com/vortex/a3/service/VortexStackOfferRetry.kt
+++ b/android/app/src/main/java/com/vortex/a3/service/VortexStackOfferRetry.kt
@@ -24,7 +24,7 @@ import kotlinx.coroutines.withTimeoutOrNull
* So every offer is tracked until the laptop has actually FETCHED it: retried
* while it can't be delivered, watched for a pull once it has been, and
* surfaced as a toast when it ends up nowhere. The stashed blob is untouched
- * either way — [com.vortex.a3.core.clipboard.ClipboardBlobStore] keeps it
+ * either way — [com.vortex.a3.core.fs.ShareGrants] keeps it
* addressable, so a later re-share of the same file is free.
*/
@@ -197,9 +197,9 @@ internal fun VortexStack.noteFileServed(token: String) {
val done = pendingOffers.remove(token) ?: return
Log.i(VortexStack.TAG, "file '${done.name}' fetched by the laptop")
// The one unambiguous "it worked" moment on this device: the laptop has the
- // bytes. Per file rather than per batch, so a slow batch shows progress as
- // it goes instead of one summary at the end.
- if (!done.quiet) toastOffer("File sent: ${done.name}")
+ // bytes. Feeds the batch's progress notification and releases the next
+ // queued file — a toast per file meant 150 toasts for a 150-file share.
+ shareQueue.noteServed(done.name)
// SLIDING deadline, like the daemon's bulk-sync idle budget: the laptop
// pulls one file per heartbeat round, so a big batch's last offer can
// legitimately wait many minutes for its turn. A fetch anywhere in the
diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexStackWifiDirect.kt b/android/app/src/main/java/com/vortex/a3/service/VortexStackWifiDirect.kt
index 9a6e819..99c200b 100644
--- a/android/app/src/main/java/com/vortex/a3/service/VortexStackWifiDirect.kt
+++ b/android/app/src/main/java/com/vortex/a3/service/VortexStackWifiDirect.kt
@@ -16,6 +16,26 @@ import kotlinx.coroutines.launch
* to join; tear it down after an idle window (reset on each big file). */
internal fun VortexStack.maybeStartWifiDirect() {
com.vortex.a3.core.lan.WifiDirect.start(ctx) {
+ // COALESCE the offer. `WifiDirect.start` is idempotent for the group but
+ // re-invokes this callback on every call (`if (isUp) onReady()`), and
+ // this runs once per big file in a share. A 65-file batch therefore sent
+ // 65 offers in about a second, and each one makes the laptop join the P2P
+ // group and then restore its normal Wi-Fi — a single adapter, so that is
+ // a full disconnect/reconnect cycle per offer. The user got a storm of
+ // Wi-Fi notifications and pulls kept getting cut off mid-transfer, which
+ // is also where the duplicate files came from.
+ //
+ // Not "only on transition": a LATER batch, arriving while the group is
+ // still up but after the laptop restored its Wi-Fi, does need telling
+ // again. A time gap satisfies both, and sits well inside the idle
+ // teardown window so a genuinely new batch is never starved.
+ val now = android.os.SystemClock.elapsedRealtime()
+ val since = now - lastWifiDirectOfferAtMs
+ if (lastWifiDirectOfferAtMs != 0L && since < VortexStack.WIFI_DIRECT_OFFER_MIN_GAP_MS) {
+ Log.d(VortexStack.TAG, "wifi-direct: offer suppressed (sent ${since}ms ago)")
+ return@start
+ }
+ lastWifiDirectOfferAtMs = now
val o = org.json.JSONObject()
o.put("ssid", com.vortex.a3.core.lan.WifiDirect.SSID)
o.put("pass", com.vortex.a3.core.lan.WifiDirect.PASS)
@@ -29,6 +49,9 @@ internal fun VortexStack.maybeStartWifiDirect() {
wifiDirectTeardownJob = scope.launch {
kotlinx.coroutines.delay(60_000)
com.vortex.a3.core.lan.WifiDirect.stop()
+ // Group is gone, so the next batch must be free to offer at once
+ // rather than waiting out the coalescing gap.
+ lastWifiDirectOfferAtMs = 0L
Log.i(VortexStack.TAG, "wifi-direct: GO torn down (idle)")
}
}
diff --git a/android/app/src/main/java/com/vortex/a3/ui/MainActivity.kt b/android/app/src/main/java/com/vortex/a3/ui/MainActivity.kt
index 12fe037..348352e 100644
--- a/android/app/src/main/java/com/vortex/a3/ui/MainActivity.kt
+++ b/android/app/src/main/java/com/vortex/a3/ui/MainActivity.kt
@@ -11,7 +11,6 @@ import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.Bundle
-import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
@@ -64,6 +63,16 @@ class MainActivity : ComponentActivity() {
* once when the LanServer enters PairingWindow mode. */
internal var pairingInstanceId: ByteArray? = null
+ /** Drives a user-opened "pair another laptop" window: the advertise +
+ * bounded-close sequence in [startPairingWindow]. Cancelled when the
+ * window is closed early (user Cancel, or a successful pair). */
+ internal var pairingWindowJob: kotlinx.coroutines.Job? = null
+
+ /** Set when [onAddPairClicked] had to ask for permissions first, so the
+ * grant callback opens a pairing window instead of falling through to
+ * the default (trusted-presence) advertising mode. */
+ internal var pendingPairingWindow = false
+
internal val state = MutableStateFlow(AdvertiseState.Idle)
internal val identityState = MutableStateFlow(null)
internal val handshakeState = MutableStateFlow(null)
@@ -167,6 +176,12 @@ class MainActivity : ComponentActivity() {
* back on. */
internal val bluetoothOff = MutableStateFlow(false)
+ /** True while a "switch laptop" seek window is open. Mirrored from the
+ * service (which owns the window and its expiry) by the same 3 s
+ * ticker that refreshes staleness, so a window that times out on its
+ * own stops showing as busy without needing a callback. */
+ internal val seekingLaptop = MutableStateFlow(false)
+
/** True while [btStateReceiver] is registered, so onPause unregisters
* exactly once (double-unregister throws). */
private var btReceiverRegistered = false
@@ -198,6 +213,8 @@ class MainActivity : ComponentActivity() {
ActivityResultContracts.RequestMultiplePermissions(),
) { granted ->
val denied = granted.filterValues { !it }.keys
+ val wantWindow = pendingPairingWindow
+ pendingPairingWindow = false
// Only a denied BLUETOOTH permission can stop us: without the radio
// there is nothing to advertise on. Declining SMS or call-log access
// costs the user those features, not the ability to pair — which is
@@ -210,7 +227,9 @@ class MainActivity : ComponentActivity() {
"pairing on without optional permissions: ${denied.joinToString()}",
)
}
- startAdvertising()
+ // "Add pair" asked for these; honour that instead of
+ // startAdvertising(), which would pick trusted-presence.
+ if (wantWindow) startPairingWindow() else startAdvertising()
} else {
state.value = AdvertiseState.Error("permissions denied: ${blocking.joinToString()}")
}
@@ -224,6 +243,80 @@ class MainActivity : ComponentActivity() {
ActivityResultContracts.RequestMultiplePermissions(),
) { /* no-op */ }
+ /**
+ * Folder picker for filesystem sharing (design doc §5).
+ *
+ * SAF trees rather than `MANAGE_EXTERNAL_STORAGE`: pairing a laptop proves
+ * identity, not authorisation, and it should not follow that the laptop can
+ * read the whole phone. The user picks exactly what is shared, and the
+ * grant Android persists IS the allowlist the server enforces — there is no
+ * second list of ours that could drift from it.
+ */
+ internal val sharedFolderLauncher = registerForActivityResult(
+ ActivityResultContracts.OpenDocumentTree(),
+ ) { uri ->
+ if (uri == null) return@registerForActivityResult // user backed out
+ if (com.vortex.a3.core.fs.FsRoots(this).grant(uri)) {
+ android.widget.Toast.makeText(
+ this,
+ "Shared with your laptop, read-only",
+ android.widget.Toast.LENGTH_SHORT,
+ ).show()
+ }
+ }
+
+ /**
+ * Open Android's all-files-access screen.
+ *
+ * Only reachable from the "Allow access to any files" setting — never on
+ * the first-run path. It is a special access, granted on a system screen we
+ * cannot skip, and the honest default is the folder picker: pairing a
+ * laptop should not quietly come to mean handing over the whole phone
+ * (design doc §5).
+ *
+ * Toggling off is Android's job too, on the same screen, so there is one
+ * place that decides and nothing of ours to keep in step.
+ */
+ internal fun openAllFilesAccess() {
+ val intents = listOf(
+ // App-specific screen first: it lands on our entry with the toggle
+ // right there.
+ android.content.Intent(
+ android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
+ android.net.Uri.parse("package:$packageName"),
+ ),
+ // Some OEM ROMs (MIUI among them) do not implement the per-app
+ // screen and throw; the global list is the documented fallback.
+ android.content.Intent(
+ android.provider.Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION,
+ ),
+ )
+ for (i in intents) {
+ try {
+ startActivity(i)
+ return
+ } catch (_: Exception) {
+ // Try the next one.
+ }
+ }
+ android.widget.Toast.makeText(
+ this,
+ "This phone has no all-files access screen",
+ android.widget.Toast.LENGTH_SHORT,
+ ).show()
+ }
+
+ /** Open the folder picker. Adding is the only action here: revoking is
+ * Android's own "remove permission" in app settings, and duplicating it
+ * would give two places that must agree about what is shared. */
+ internal fun pickSharedFolder() {
+ try {
+ sharedFolderLauncher.launch(null)
+ } catch (e: Exception) {
+ android.util.Log.w("VortexFs", "no document picker available: ${e.message}")
+ }
+ }
+
/** Dedicated READ_PHONE_STATE request used on the trusted-launch path
* (which starts the service directly, bypassing the BLE permission
* flow). On any result we nudge the service to (re)register the
@@ -249,20 +342,6 @@ class MainActivity : ComponentActivity() {
/** The folder picker behind "let the laptop browse a folder". The grant is
* persisted on the way back so it survives a restart; a cancelled pick
* returns null and simply changes nothing. */
- private val folderPickLauncher = registerForActivityResult(
- ActivityResultContracts.OpenDocumentTree(),
- ) { uri ->
- if (uri != null) com.vortex.a3.core.files.PhoneFiles.persistGrant(this, uri)
- }
-
- internal fun pickSharedFolder() {
- try {
- folderPickLauncher.launch(null)
- } catch (e: Exception) {
- android.util.Log.w("PhoneFiles", "no folder picker available: ${e.message}")
- }
- }
-
internal fun requestMediaPermission() {
val missing = com.vortex.a3.core.media.mediaReadPermissions().filter {
androidx.core.content.ContextCompat.checkSelfPermission(this, it) !=
@@ -274,11 +353,18 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- // Dev-only: keep the screen on so the lab tester can read the
- // generated identity. Production removes this.
- window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
- window.addFlags(WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON)
- window.addFlags(WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD)
+ // No KEEP_SCREEN_ON / TURN_SCREEN_ON / DISMISS_KEYGUARD here. They were
+ // lab scaffolding — "keep the screen on so the tester can read the
+ // generated identity" — and left the phone unable to sleep for as long
+ // as Vortex was in front, with the keyguard flag quietly waiving a
+ // swipe lock screen whenever this activity came up. Nothing in the app
+ // depends on them: the phone-to-laptop mirror holds its own
+ // SCREEN_DIM_WAKE_LOCK inside ScreenMirrorService (it has to, since it
+ // keeps capturing with the activity gone), LaptopMirrorActivity sets
+ // its own FLAG_KEEP_SCREEN_ON while you watch the laptop, and
+ // RingActivity wakes the screen with setShowWhenLocked/setTurnScreenOn.
+ // If a screen ever genuinely needs to stay lit — the pairing SAS, say —
+ // scope the flag to that screen, not to the whole activity.
uiSettings.load() // saved locale + theme
val identity = identityStore.loadOrGenerate(Platform.Android)
identityState.value = identity
@@ -345,6 +431,10 @@ class MainActivity : ComponentActivity() {
lifecycleScope.launch {
while (isActive) {
nowTickState.value = System.currentTimeMillis()
+ // The service owns the seek window and its 45 s expiry, so mirror
+ // it rather than tracking a second copy here — otherwise a window
+ // that times out on its own would keep showing as busy.
+ seekingLaptop.value = VortexService.isSeeking()
delay(3_000)
}
}
@@ -416,11 +506,16 @@ class MainActivity : ComponentActivity() {
showNotifAccessDialog = showNotifAccessDialog,
showAutostartDialog = showAutostartDialog,
bluetoothOff = bluetoothOff,
+ seekingLaptop = seekingLaptop,
)
/** Bundle the activity's callbacks for the root composable. */
private fun buildActions(): VortexActions = VortexActions(
onForgetPeer = ::onForgetPeerClicked,
+ onAddPair = ::onAddPairClicked,
+ onCancelAddPair = ::endPairingWindow,
+ onSwitchLaptop = ::onSwitchLaptopClicked,
+ onSwitchToPeer = ::onSwitchToPeerClicked,
onOpenAutostart = ::onOpenAutostartSettings,
onDismissAutostartHint = ::dismissAutostartHint,
onRequestBatteryWhitelist = ::onRequestBatteryWhitelist,
@@ -436,6 +531,7 @@ class MainActivity : ComponentActivity() {
onOpenScreenControl = ::onOpenAccessibilitySettings,
onRequestMediaPermission = ::requestMediaPermission,
onPickSharedFolder = ::pickSharedFolder,
+ onOpenAllFilesAccess = ::openAllFilesAccess,
onEnableBluetooth = ::onEnableBluetooth,
isAggressiveOem = isAggressiveOemRom(),
isIgnoringBatteryOptimizations = ::isIgnoringBatteryOptimizations,
diff --git a/android/app/src/main/java/com/vortex/a3/ui/MainActivityPairing.kt b/android/app/src/main/java/com/vortex/a3/ui/MainActivityPairing.kt
index db6a7cd..3102f12 100644
--- a/android/app/src/main/java/com/vortex/a3/ui/MainActivityPairing.kt
+++ b/android/app/src/main/java/com/vortex/a3/ui/MainActivityPairing.kt
@@ -78,6 +78,11 @@ internal fun MainActivity.wirePairingOrchestrator(identity: IdentityRecord) {
} catch (e: Exception) {
android.util.Log.w("Pairing", "createBond: ${e.message}")
}
+ // Remember the laptop's BD_ADDR so Forget can clear any BT
+ // bond for it later (see PeerStore.loadPeerBtAddr). The
+ // central's address here is the laptop's public static
+ // address, so it stays valid for the life of the pairing.
+ peerStore.savePeerBtAddr(outcome.peerStaticPub, outcome.device.address)
refreshPeerList()
state.value = AdvertiseState.TrustedPresence
// Hand off to the background service: stop our
@@ -128,6 +133,155 @@ internal fun MainActivity.startPairingWindowLanIfUntrusted(identity: IdentityRec
}
}
+/**
+ * How long a user-opened "pair another laptop" window stays open before
+ * presence advertising is restored.
+ *
+ * Bounded on purpose: the window *preempts* the trusted-presence beacon, so
+ * an indefinitely-open one would leave this phone invisible to the laptop it
+ * is already paired with (and, worse, would read as "away" to that laptop's
+ * proximity auto-lock).
+ */
+internal const val PAIRING_WINDOW_MS = 120_000L
+
+/**
+ * Open a pairing window even though trust already exists — the phone-side
+ * counterpart of the laptop's "Add phone".
+ *
+ * Until now pairable mode was reachable only with an EMPTY peer list
+ * (`onResume`'s auto-start and `selectLaunchMode`), so a phone that had ever
+ * paired could not be offered to a second laptop without forgetting the
+ * first. That is the single-peer trap this unblocks.
+ *
+ * Why it has to preempt rather than run alongside presence: `Advertiser`
+ * holds one advertising set and `startWith` refuses while another is active,
+ * and once trust exists VortexService owns the radio, the GATT server and the
+ * LAN listener (an Activity-local LanServer would race it for port 51820).
+ * So the window stops the service, advertises pairable from the Activity, and
+ * hands the radio back when it closes.
+ */
+internal fun MainActivity.onAddPairClicked() {
+ val needed = requiredPermissions().filter {
+ ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
+ }
+ if (needed.isEmpty()) {
+ startPairingWindow()
+ } else {
+ // Route the post-grant callback to the window instead of the default
+ // advertising mode, which would pick trusted-presence and silently do
+ // the opposite of what the user asked for.
+ pendingPairingWindow = true
+ permissionLauncher.launch(needed.toTypedArray())
+ }
+}
+
+internal fun MainActivity.startPairingWindow() {
+ val identity = identityState.value ?: run {
+ state.value = AdvertiseState.Error("identity not ready")
+ return
+ }
+ pairingWindowJob?.cancel()
+ state.value = AdvertiseState.Starting
+ // Release the radio, GATT server and LAN listener from the service first.
+ VortexService.stop(applicationContext)
+ pairingWindowJob = lifecycleScope.launch {
+ // The service tears down asynchronously; advertising before its
+ // BluetoothGattServer.close() lands makes our own start() fail with a
+ // busy adapter. Same reasoning as the 600 ms hand-off delay used when
+ // pairing completes, in the other direction.
+ delay(600)
+ val instanceId = ByteArray(8).also { java.security.SecureRandom().nextBytes(it) }
+ pairingInstanceId = instanceId
+ // mDNS instance must match the BLE payload_8 (spec §5.4) so a
+ // discoverer correlates the two transports.
+ lanServer = LanServer(applicationContext, identity, peerStore).also {
+ it.start(LanServerMode.PairingWindow(instanceId))
+ }
+ if (!gattServer.start()) {
+ state.value = AdvertiseState.Error("failed to start GATT server")
+ endPairingWindow()
+ return@launch
+ }
+ advertiser.startPairableAdvertiseWith(instanceId) { result ->
+ state.value = when (result) {
+ is Advertiser.StartResult.Started -> AdvertiseState.Active(result.payload)
+ is Advertiser.StartResult.Failed -> AdvertiseState.Error(result.reason)
+ }
+ }
+ delay(PAIRING_WINDOW_MS)
+ // Still pairable → nobody paired; close up and go back to presence.
+ // A successful pair already restarted the service via BothApproved,
+ // which leaves state at TrustedPresence, so this is a no-op then.
+ if (state.value is AdvertiseState.Active || state.value is AdvertiseState.Starting) {
+ endPairingWindow()
+ }
+ }
+}
+
+/** Close the window and give the radio back to the service. */
+internal fun MainActivity.endPairingWindow() {
+ pairingWindowJob?.cancel()
+ pairingWindowJob = null
+ advertiser.stopAll()
+ gattServer.stop()
+ lanServer?.stop()
+ lanServer = null
+ if (peerStore.list().isNotEmpty()) {
+ VortexService.start(applicationContext)
+ state.value = AdvertiseState.TrustedPresence
+ } else {
+ state.value = AdvertiseState.Idle
+ }
+}
+
+/**
+ * "Switch laptop": look for another remembered laptop while staying connected
+ * to the current one.
+ *
+ * Seek before release (design doc §D3) — nothing is dropped here. The service
+ * holds the current link for the whole window and only the arrival of a
+ * different laptop ends it, so a cancelled or fruitless seek leaves the phone
+ * exactly where it was. Pressing again while a window is open closes it, which
+ * doubles as Cancel without spending card space on a second control.
+ */
+internal fun MainActivity.onSwitchLaptopClicked() {
+ if (VortexService.isSeeking()) {
+ VortexService.stopSeeking()
+ seekingLaptop.value = false
+ return
+ }
+ val started = VortexService.startSeeking()
+ seekingLaptop.value = started
+ if (!started) {
+ // Refused: no stack running, or fewer than two remembered laptops. The
+ // card only offers the action with 2+, so this is the service-down case.
+ android.util.Log.i("VortexSwitch", "seek not started (service down?)")
+ }
+}
+
+/**
+ * Switch to one specific remembered laptop.
+ *
+ * Unlike [onSwitchLaptopClicked] this names the destination, so the seek
+ * advertises only that peer's token rather than cycling every remembered one —
+ * found as fast as the single-peer case, and less time on air.
+ */
+internal fun MainActivity.onSwitchToPeerClicked(peer: TrustedPeer) {
+ if (VortexService.isSeeking()) {
+ // Already looking; a second tap would only change the target
+ // mid-flight. Treat it as cancel, matching the header action.
+ VortexService.stopSeeking()
+ seekingLaptop.value = false
+ return
+ }
+ val started = VortexService.startSeeking(peer.peerStaticPub)
+ seekingLaptop.value = started
+ android.util.Log.i(
+ "VortexSwitch",
+ "targeted seek for '${peer.peerName ?: "laptop"}' started=$started",
+ )
+}
+
internal fun MainActivity.onApproveClicked(outcome: PairingOrchestrator.HandshakeOutcome) {
val orch = pairingOrchestrator ?: return
val frame = orch.buildLocalApprovalFrame(
@@ -187,6 +341,20 @@ internal fun MainActivity.onForgetPeerClicked(peer: TrustedPeer) {
// be offline forever.
kotlinx.coroutines.delay(1_500)
VortexService.pendingRevokes.remove(hex)
+ // Drop any BT bond for this laptop BEFORE forgetting, while its
+ // address is still on file. Vortex never creates these bonds (Linux
+ // deliberately skips Device::pair()), but one added via the desktop's
+ // Bluetooth panel or left by an older build survives the peer-store
+ // wipe — and a bond the laptop no longer holds makes the next pairing
+ // fail during encryption with `timeout: service discovery`, which is
+ // the "retry several times until it works" symptom. Android also
+ // hides profile-less LE bonds from Settings, so this is the only way
+ // for the user to clear one.
+ peerStore.loadPeerBtAddr(peer.peerStaticPub)?.let { mac ->
+ val btAdapter =
+ getSystemService(android.bluetooth.BluetoothManager::class.java)?.adapter
+ btAdapter?.let { com.vortex.a3.core.ble.BondCleaner.removeBond(it, mac) }
+ }
peerStore.forget(peer.peerStaticPub)
refreshPeerList()
if (peerStore.list().isEmpty()) {
@@ -200,7 +368,12 @@ internal fun MainActivity.onForgetAllClicked() {
// Legacy entrypoint — kept around for any future Settings page
// 'Danger zone' but no longer wired into the home screen.
VortexService.stop(applicationContext)
+ val btAdapter = getSystemService(android.bluetooth.BluetoothManager::class.java)?.adapter
for (peer in peerStore.list()) {
+ // Same bond cleanup as the single-peer path — see onForgetPeerClicked.
+ peerStore.loadPeerBtAddr(peer.peerStaticPub)?.let { mac ->
+ btAdapter?.let { com.vortex.a3.core.ble.BondCleaner.removeBond(it, mac) }
+ }
peerStore.forget(peer.peerStaticPub)
}
refreshPeerList()
diff --git a/android/app/src/main/java/com/vortex/a3/ui/Strings.kt b/android/app/src/main/java/com/vortex/a3/ui/Strings.kt
index 78e8833..b09e107 100644
--- a/android/app/src/main/java/com/vortex/a3/ui/Strings.kt
+++ b/android/app/src/main/java/com/vortex/a3/ui/Strings.kt
@@ -52,6 +52,11 @@ private val EN = mapOf(
"peers.forget_title" to "Forget device",
"peers.forget_body" to "Stop trusting %s? You'll need to re-pair to connect again.",
"peers.forget_confirm" to "Forget",
+ "peers.add_pair" to "Pair another laptop",
+ "peers.other_title" to "Also paired",
+ "peers.other_hint" to "Tap to switch",
+ "peers.add_pair_cancel" to "Cancel",
+ "peers.add_pair_hint" to "Make this phone discoverable so a second laptop can pair with it. Your current laptop won't see the phone while the window is open.",
"earbuds.not_connected" to "Not connected",
"earbuds.on_local" to "Connected here",
"earbuds.on_peer" to "Connected to laptop",
@@ -189,6 +194,11 @@ private val UZ = mapOf(
"peers.forget_title" to "Qurilmani unutish",
"peers.forget_body" to "%s bilan aloqa uziladi. Qayta ulanish uchun yana pairing qilinadi.",
"peers.forget_confirm" to "Unutish",
+ "peers.add_pair" to "Boshqa laptop qo'shish",
+ "peers.other_title" to "Yana ulangan",
+ "peers.other_hint" to "O'tish uchun bosing",
+ "peers.add_pair_cancel" to "Bekor qilish",
+ "peers.add_pair_hint" to "Ikkinchi laptop ulanishi uchun telefonni ko'rinadigan qiling. Oyna ochiq bo'lganda hozirgi laptop telefonni ko'rmaydi.",
"earbuds.not_connected" to "Ulangmagan",
"earbuds.on_local" to "Bu telefonga ulangan",
"earbuds.on_peer" to "Noutbukka ulangan",
@@ -326,6 +336,11 @@ private val RU = mapOf(
"peers.forget_title" to "Забыть устройство",
"peers.forget_body" to "Перестать доверять %s? Чтобы вновь подключиться, потребуется повторное сопряжение.",
"peers.forget_confirm" to "Забыть",
+ "peers.add_pair" to "Добавить ноутбук",
+ "peers.other_title" to "Также сопряжены",
+ "peers.other_hint" to "Нажмите, чтобы переключиться",
+ "peers.add_pair_cancel" to "Отмена",
+ "peers.add_pair_hint" to "Сделать телефон видимым, чтобы с ним мог связаться второй ноутбук. Пока окно открыто, текущий ноутбук телефон не видит.",
"earbuds.not_connected" to "Не подключены",
"earbuds.on_local" to "Подключены к телефону",
"earbuds.on_peer" to "Подключены к ноутбуку",
diff --git a/android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt b/android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt
index 1da4b17..efc40b6 100644
--- a/android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt
+++ b/android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt
@@ -1,6 +1,7 @@
package com.vortex.a3.ui
import androidx.activity.ComponentActivity
+import android.view.WindowManager
import androidx.activity.compose.BackHandler
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
@@ -10,6 +11,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -63,6 +65,8 @@ class MainUiState(
val showAutostartDialog: MutableStateFlow,
/** Bluetooth adapter is off/absent → drives the home "turn on BT" banner. */
val bluetoothOff: StateFlow,
+ /** A "switch laptop" seek window is open. */
+ val seekingLaptop: StateFlow,
)
/**
@@ -75,6 +79,14 @@ class MainUiState(
*/
class VortexActions(
val onForgetPeer: (TrustedPeer) -> Unit,
+ /** Open a pairing window while trust already exists, so this phone
+ * can be offered to a second laptop without forgetting the first. */
+ val onAddPair: () -> Unit,
+ val onCancelAddPair: () -> Unit,
+ /** Look for another remembered laptop while staying on this one. */
+ val onSwitchLaptop: () -> Unit,
+ /** Switch to one NAMED laptop; lets the seek advertise a single token. */
+ val onSwitchToPeer: (TrustedPeer) -> Unit,
val onOpenAutostart: () -> Unit,
val onDismissAutostartHint: () -> Unit,
val onRequestBatteryWhitelist: () -> Unit,
@@ -94,6 +106,7 @@ class VortexActions(
/** Ask for the storage grant the media-share toggles need (no-op if held). */
val onRequestMediaPermission: () -> Unit,
val onPickSharedFolder: () -> Unit,
+ val onOpenAllFilesAccess: () -> Unit,
/** Ask the system to turn Bluetooth on (one-tap dialog). */
val onEnableBluetooth: () -> Unit,
val isAggressiveOem: Boolean,
@@ -126,6 +139,26 @@ fun VortexRoot(
@Suppress("DEPRECATION")
window.statusBarColor = colorScheme.background.toArgb()
}
+ // Hold the screen on, but only while the phone is waiting on another device
+ // to find it: the pairing window, the SAS comparison, and the switch-laptop
+ // seek. Those are the bounded stretches where the user is reading the screen
+ // without touching it, and a display timeout in the middle of a handshake
+ // takes the radio work down with it. Everywhere else the phone is free to
+ // sleep — MainActivity.onCreate deliberately sets no window-level
+ // KEEP_SCREEN_ON, which used to keep the display up for as long as Vortex
+ // was in front.
+ val advertising by ui.advertise.collectAsState()
+ val awaitingSas by ui.pendingApproval.collectAsState()
+ val seekingLaptop by ui.seekingLaptop.collectAsState()
+ val holdScreenOn = advertising is AdvertiseState.Starting ||
+ advertising is AdvertiseState.Active ||
+ awaitingSas != null ||
+ seekingLaptop
+ DisposableEffect(holdScreenOn) {
+ val w = activity.window
+ if (holdScreenOn) w.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ onDispose { w.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) }
+ }
CompositionLocalProvider(LocalVortexLocale provides activeLocale) {
MaterialTheme(colorScheme = colorScheme) {
Surface(
@@ -134,6 +167,7 @@ fun VortexRoot(
) {
var showSettings by remember { mutableStateOf(false) }
var showNotes by remember { mutableStateOf(false) }
+ var showLaptopFiles by remember { mutableStateOf(false) }
remember { com.vortex.a3.core.notes.NoteStore.init(activity); 0 }
// Shared smart-switch setting (persisted + cross-device LWW).
remember { SmartSwitchSetting.init(activity); 0 }
@@ -159,9 +193,7 @@ fun VortexRoot(
// Re-read whenever the settings screen is shown: the picker is
// a separate activity, so a grant taken there lands while this
// composition is away.
- val sharedFolderCount = remember(showSettings) {
- com.vortex.a3.core.files.PhoneFiles.grantedTrees(activity).size
- }
+
// Re-checked each time Settings opens AND after either toggle
// moves: the flip that turns a row on is what asks for the
// grant, and the hint should follow the answer.
@@ -178,7 +210,21 @@ fun VortexRoot(
val screenControlOn = remember(showSettings) {
com.vortex.a3.service.VortexInputService.isEnabled(activity)
}
- if (showNotes) {
+ // Re-read when Settings opens: the user may have added or
+ // revoked a grant since, including from system settings.
+ val sharedFolderCount = remember(showSettings) {
+ com.vortex.a3.core.fs.FsRoots(activity).roots().size
+ }
+ val allFilesOn = remember(showSettings) {
+ com.vortex.a3.core.fs.FsRoots(activity).allFilesGranted()
+ }
+ if (showLaptopFiles) {
+ // Its own BackHandler walks up the folder stack first, so
+ // Back only leaves the screen from the top level.
+ com.vortex.a3.ui.screens.LaptopFilesScreen(
+ onBack = { showLaptopFiles = false },
+ )
+ } else if (showNotes) {
// System back pops to Home instead of leaving the app.
// NotesScreen's own handlers (close the editor) compose
// later, so they still win while the editor is open.
@@ -240,6 +286,8 @@ fun VortexRoot(
onPickSharedFolder = actions.onPickSharedFolder,
screenControlOn = screenControlOn,
onScreenControlClick = actions.onOpenScreenControl,
+ allFilesOn = allFilesOn,
+ onAllFilesClick = actions.onOpenAllFilesAccess,
onBack = { showSettings = false },
)
} else {
@@ -257,11 +305,17 @@ fun VortexRoot(
pickerState = ui.picker.collectAsState().value,
switchState = EarbudsSwitchHolder.state.collectAsState().value,
onForgetPeer = actions.onForgetPeer,
+ onAddPair = actions.onAddPair,
+ onCancelAddPair = actions.onCancelAddPair,
+ onSwitchLaptop = actions.onSwitchLaptop,
+ onSwitchToPeer = actions.onSwitchToPeer,
+ seekingLaptop = ui.seekingLaptop.collectAsState().value,
onOpenAutostart = actions.onOpenAutostart,
onDismissAutostartHint = actions.onDismissAutostartHint,
onRequestBatteryWhitelist = actions.onRequestBatteryWhitelist,
onOpenSettings = { showSettings = true },
onOpenNotes = { showNotes = true },
+ onOpenLaptopFiles = { showLaptopFiles = true },
onOpenEarbudsPicker = actions.onOpenEarbudsPicker,
onPickEarbud = actions.onPickEarbud,
onRescanEarbuds = actions.onRescanEarbuds,
diff --git a/android/app/src/main/java/com/vortex/a3/ui/components/Common.kt b/android/app/src/main/java/com/vortex/a3/ui/components/Common.kt
index 4809db9..1bb5cd0 100644
--- a/android/app/src/main/java/com/vortex/a3/ui/components/Common.kt
+++ b/android/app/src/main/java/com/vortex/a3/ui/components/Common.kt
@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.CircleShape
@@ -69,20 +70,32 @@ fun CardHeader(
iconTint: Color,
iconBg: Color,
statusDot: Color,
+ /** Optional action rendered immediately right of the device icon. Card
+ * actions that are not per-row belong here rather than in the bottom row,
+ * which is already carrying battery + charging + per-feature glyphs. */
+ afterIcon: (@Composable () -> Unit)? = null,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) {
- Box(
- modifier = Modifier
- .size(42.dp)
- .clip(RoundedCornerShape(12.dp))
- .background(iconBg),
- contentAlignment = Alignment.Center,
- ) {
- Icon(imageVector = icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(22.dp))
+ // Icon and its trailing action are grouped so SpaceBetween keeps them
+ // together on the left instead of spreading three items across the row.
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(
+ modifier = Modifier
+ .size(42.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .background(iconBg),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(imageVector = icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(22.dp))
+ }
+ if (afterIcon != null) {
+ Spacer(modifier = Modifier.size(10.dp))
+ afterIcon()
+ }
}
StatusDot(color = statusDot)
}
diff --git a/android/app/src/main/java/com/vortex/a3/ui/components/OtherPeersCard.kt b/android/app/src/main/java/com/vortex/a3/ui/components/OtherPeersCard.kt
new file mode 100644
index 0000000..85eeab0
--- /dev/null
+++ b/android/app/src/main/java/com/vortex/a3/ui/components/OtherPeersCard.kt
@@ -0,0 +1,150 @@
+package com.vortex.a3.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Laptop
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.font.FontWeight as FW
+import androidx.compose.ui.unit.dp
+import com.vortex.a3.core.storage.TrustedPeer
+import com.vortex.a3.ui.str
+
+/**
+ * The laptops this phone is paired with but not currently using.
+ *
+ * Compact rows rather than full [PeerDeviceCard]s: those are 180 dp each, so a
+ * card per laptop would push everything else off the screen for information
+ * that is mostly "this one exists and is not the one you are on".
+ *
+ * Tapping a row switches to THAT laptop. That is deliberately more specific
+ * than the card header's generic unlink action: naming the destination lets the
+ * seek advertise a single token instead of cycling every remembered peer, so it
+ * is both faster and cheaper on air (design doc §D1).
+ */
+@Composable
+fun OtherPeersCard(
+ peers: List,
+ lastSeen: Map,
+ now: Long,
+ seeking: Boolean,
+ onSwitchTo: (TrustedPeer) -> Unit,
+) {
+ if (peers.isEmpty()) return
+ SurfaceCard {
+ Text(
+ str("peers.other_title"),
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FW.SemiBold,
+ style = MaterialTheme.typography.titleSmall,
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ str("peers.other_hint"),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ for (peer in peers) {
+ Spacer(modifier = Modifier.height(10.dp))
+ val hex = peer.peerStaticPub.toHex()
+ val seen = lastSeen[hex] ?: 0L
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(10.dp))
+ // The whole row is the target: a 20 dp glyph is a poor tap
+ // area, and there is only one action per row anyway.
+ .clickable(enabled = !seeking) { onSwitchTo(peer) }
+ .padding(vertical = 6.dp, horizontal = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Box(
+ modifier = Modifier
+ .size(34.dp)
+ .clip(RoundedCornerShape(10.dp))
+ .background(
+ MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.12f),
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.Laptop,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(18.dp),
+ )
+ }
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ peer.peerName?.takeIf { it.isNotBlank() } ?: str("device.linux"),
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 1,
+ )
+ Text(
+ // Never claim a peer is reachable: these are the ones we
+ // are NOT connected to, so the honest thing to show is
+ // when it was last heard from.
+ lastSeenLabel(seen, peer.pairedAt, now),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.bodySmall,
+ maxLines = 1,
+ )
+ }
+ // No trailing "Switch" label: the row IS the button and the
+ // heading already says so, so a per-row repeat is noise. Only
+ // the in-flight spinner earns space here.
+ if (seeking) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(16.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ }
+ }
+}
+
+/**
+ * "seen 12 min ago", or "paired 2 d ago" when we have not heard from it.
+ *
+ * [seenMs] only covers peers heard from during THIS app process — it is not
+ * persisted — so a laptop paired yesterday reads as never-seen after a restart.
+ * "not seen yet" would be true of the session and false to the user, who
+ * remembers pairing it. Falling back to [pairedAtSec] says something both
+ * accurate and useful.
+ */
+private fun lastSeenLabel(seenMs: Long, pairedAtSec: Long, nowMs: Long): String {
+ if (seenMs > 0L) return "seen ${ago((nowMs - seenMs) / 1000)}"
+ if (pairedAtSec > 0L) return "paired ${ago(nowMs / 1000 - pairedAtSec)}"
+ return "not connected"
+}
+
+private fun ago(secs: Long): String {
+ val s = secs.coerceAtLeast(0)
+ return when {
+ s < 60 -> "just now"
+ s < 3600 -> "${s / 60} min ago"
+ s < 86_400 -> "${s / 3600} h ago"
+ else -> "${s / 86_400} d ago"
+ }
+}
diff --git a/android/app/src/main/java/com/vortex/a3/ui/components/PeerDeviceCard.kt b/android/app/src/main/java/com/vortex/a3/ui/components/PeerDeviceCard.kt
index 4924d8e..693f87d 100644
--- a/android/app/src/main/java/com/vortex/a3/ui/components/PeerDeviceCard.kt
+++ b/android/app/src/main/java/com/vortex/a3/ui/components/PeerDeviceCard.kt
@@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Cast
+import androidx.compose.material.icons.outlined.PhonelinkOff
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.LockOpen
import androidx.compose.material3.Icon
@@ -62,6 +63,11 @@ fun PeerDeviceCard(
/** Tap to view the laptop's screen on this phone (laptop→phone mirror).
* Null hides the action (e.g. the peer isn't a laptop / not reachable). */
onViewScreen: (() -> Unit)? = null,
+ /** Tap to look for another remembered laptop while staying on this one.
+ * Null hides the action (fewer than two laptops remembered). */
+ onSwitch: (() -> Unit)? = null,
+ /** True while a seek window is open — shows the action as busy. */
+ seeking: Boolean = false,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
@@ -95,6 +101,29 @@ fun PeerDeviceCard(
iconTint = MaterialTheme.colorScheme.primary,
iconBg = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f),
statusDot = statusDotColor,
+ // Beside the device icon, not in the bottom row. Two facing arrows
+ // sandwiched between the cast and lock glyphs read as "swap those
+ // two", and it crowded the row enough to wrap the battery
+ // percentage onto a second line. "Unlink" says what this does:
+ // leave this laptop for another one.
+ afterIcon = onSwitch?.let { switch ->
+ {
+ Icon(
+ imageVector = Icons.Outlined.PhonelinkOff,
+ contentDescription = "Switch to another laptop",
+ tint = if (seeking) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ modifier = Modifier
+ .clip(RoundedCornerShape(8.dp))
+ .clickable(onClick = switch)
+ .padding(4.dp)
+ .size(20.dp),
+ )
+ }
+ },
)
Spacer(modifier = Modifier.height(14.dp))
Text(name, color = MaterialTheme.colorScheme.onSurface, fontWeight = FW.SemiBold, style = MaterialTheme.typography.bodyLarge, maxLines = 1)
diff --git a/android/app/src/main/java/com/vortex/a3/ui/screens/HomeScreen.kt b/android/app/src/main/java/com/vortex/a3/ui/screens/HomeScreen.kt
index 89ecc98..7c6baf7 100644
--- a/android/app/src/main/java/com/vortex/a3/ui/screens/HomeScreen.kt
+++ b/android/app/src/main/java/com/vortex/a3/ui/screens/HomeScreen.kt
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -20,6 +21,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Laptop
+import androidx.compose.material.icons.outlined.FolderOpen
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Smartphone
import androidx.compose.material.icons.outlined.StickyNote2
@@ -59,6 +61,7 @@ import com.vortex.a3.ui.components.CardCorner
import com.vortex.a3.ui.components.EarbudsCard
import com.vortex.a3.ui.components.EarbudsPickerDialog
import com.vortex.a3.ui.components.HintCard
+import com.vortex.a3.ui.components.OtherPeersCard
import com.vortex.a3.ui.components.PeerDeviceCard
import com.vortex.a3.ui.components.SurfaceCard
import com.vortex.a3.ui.components.VortexDivider
@@ -87,11 +90,17 @@ fun HomeScreen(
pickerState: PickerState,
switchState: SwitchState,
onForgetPeer: (TrustedPeer) -> Unit,
+ onAddPair: () -> Unit,
+ onCancelAddPair: () -> Unit,
+ onSwitchLaptop: () -> Unit,
+ onSwitchToPeer: (TrustedPeer) -> Unit,
+ seekingLaptop: Boolean,
onOpenAutostart: () -> Unit,
onDismissAutostartHint: () -> Unit,
onRequestBatteryWhitelist: () -> Unit,
onOpenSettings: () -> Unit,
onOpenNotes: () -> Unit,
+ onOpenLaptopFiles: () -> Unit,
onOpenEarbudsPicker: () -> Unit,
onPickEarbud: (BluetoothDeviceRow) -> Unit,
onRescanEarbuds: () -> Unit,
@@ -104,7 +113,18 @@ fun HomeScreen(
onEnableBluetooth: () -> Unit,
) {
val peerCount = peers.size
- val primaryPeer = peers.firstOrNull()
+ // Show the laptop we are actually talking to.
+ //
+ // This used to be `peers.firstOrNull()`, which with two trusted laptops
+ // meant "an arbitrary one" — observed showing a laptop 40 km away as
+ // "Disconnected" while the phone was happily syncing with the one on the
+ // desk. Freshest traffic wins; `pairedAt` breaks ties so the choice is
+ // still deterministic when nothing has been heard from yet (and then it is
+ // most-recently-paired, which is the best available guess at "yours").
+ val primaryPeer = peers.maxWithOrNull(
+ compareBy { peerLastSeen[it.peerStaticPub.toHex()] ?: 0L }
+ .thenBy { it.pairedAt },
+ )
val primaryState = primaryPeer?.let { peerStates[it.peerStaticPub.toHex()] }
val primaryHex = primaryPeer?.peerStaticPub?.toHex()
val lastSeen = primaryHex?.let { peerLastSeen[it] } ?: 0L
@@ -145,7 +165,15 @@ fun HomeScreen(
Column(
modifier = Modifier
.fillMaxSize()
- .background(MaterialTheme.colorScheme.background),
+ .background(MaterialTheme.colorScheme.background)
+ // targetSdk 36 makes edge-to-edge mandatory and nothing here opted
+ // in, so this header drew UNDER the status bar: the Notes / Laptop
+ // files / Settings icons sat in the same band as the clock, where
+ // the system consumes the touch. They rendered fine and simply did
+ // not respond, which reads as a broken button rather than a
+ // mispositioned one. Background before padding, so the status bar
+ // still sits on our colour instead of a bare strip.
+ .systemBarsPadding(),
) {
Row(
modifier = Modifier
@@ -187,6 +215,13 @@ fun HomeScreen(
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
+ IconButton(onClick = onOpenLaptopFiles) {
+ Icon(
+ imageVector = Icons.Outlined.FolderOpen,
+ contentDescription = "Laptop files",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
IconButton(onClick = onOpenSettings) {
Icon(
imageVector = Icons.Outlined.Settings,
@@ -263,6 +298,10 @@ fun HomeScreen(
} else {
null
},
+ // Only offered with a second laptop to switch TO —
+ // otherwise the seek can only ever time out.
+ onSwitch = if (peerCount > 1) onSwitchLaptop else null,
+ seeking = seekingLaptop,
)
EarbudsCard(
@@ -277,6 +316,50 @@ fun HomeScreen(
onRemoveSaved = onRemoveSavedEarbuds,
)
}
+
+ // Every other paired laptop, tappable to switch to it.
+ // Compact rows: a 180 dp card each would push the rest of the
+ // screen away for what is mostly "this one exists".
+ OtherPeersCard(
+ peers = peers.filter { it !== primaryPeer },
+ lastSeen = peerLastSeen,
+ now = now,
+ seeking = seekingLaptop,
+ onSwitchTo = onSwitchToPeer,
+ )
+
+ // "Pair another laptop". A pairable window preempts the
+ // trusted-presence beacon (one advertising set), so it is
+ // explicit and bounded rather than always-on: while it is
+ // open the already-paired laptop cannot see this phone.
+ val windowOpen =
+ state is AdvertiseState.Active || state is AdvertiseState.Starting
+ SurfaceCard {
+ if (windowOpen) {
+ Text(
+ str("discover.title"),
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FW.SemiBold,
+ style = MaterialTheme.typography.titleSmall,
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ WaitingForLinuxRow(state = state)
+ Spacer(modifier = Modifier.height(12.dp))
+ TextButton(onClick = onCancelAddPair) {
+ Text(str("peers.add_pair_cancel"))
+ }
+ } else {
+ Text(
+ str("peers.add_pair_hint"),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ Button(onClick = onAddPair) {
+ Text(str("peers.add_pair"))
+ }
+ }
+ }
}
if (showBatteryHint && peerCount > 0) {
diff --git a/android/app/src/main/java/com/vortex/a3/ui/screens/LaptopFilesScreen.kt b/android/app/src/main/java/com/vortex/a3/ui/screens/LaptopFilesScreen.kt
new file mode 100644
index 0000000..82b4bc8
--- /dev/null
+++ b/android/app/src/main/java/com/vortex/a3/ui/screens/LaptopFilesScreen.kt
@@ -0,0 +1,286 @@
+package com.vortex.a3.ui.screens
+
+import android.os.Environment
+import android.widget.Toast
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.ArrowBack
+import androidx.compose.material.icons.outlined.Description
+import androidx.compose.material.icons.outlined.Folder
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.vortex.a3.core.fs.FsClient
+import com.vortex.a3.core.fs.FsCode
+import com.vortex.a3.core.fs.FsEntry
+import java.io.File
+import kotlinx.coroutines.launch
+
+/**
+ * Browse the laptop's shared folders and pull files down.
+ *
+ * Deliberately thin: everything it shows comes from [FsClient], and the laptop
+ * decides what is visible through its own roots config. This screen never
+ * constructs a path — it sends back the opaque `path` of an entry it was given,
+ * which is what lets the same code work whether the far side is a real
+ * filesystem or something else entirely.
+ */
+@Composable
+fun LaptopFilesScreen(onBack: () -> Unit) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+
+ // The trail of folders we descended, so Back walks up one level rather than
+ // leaving the screen from three levels deep.
+ var stack by remember { mutableStateOf(listOf>()) } // (path, title)
+ var entries by remember { mutableStateOf>(emptyList()) }
+ var loading by remember { mutableStateOf(true) }
+ var error by remember { mutableStateOf(null) }
+ var downloading by remember { mutableStateOf(null) }
+ var progress by remember { mutableStateOf(0f) }
+
+ val path = stack.lastOrNull()?.first ?: ""
+ val title = stack.lastOrNull()?.second ?: "Laptop files"
+
+ LaunchedEffect(path) {
+ loading = true
+ error = null
+ try {
+ entries = FsClient.listAll(path).sortedWith(
+ // Folders first, then case-insensitive by name — what every
+ // file manager does, and cheap to do here rather than asking
+ // the far side to sort.
+ compareBy({ !it.isDir }, { it.name.lowercase() }),
+ )
+ } catch (e: FsClient.FsException) {
+ entries = emptyList()
+ error = explain(e)
+ } catch (e: Exception) {
+ entries = emptyList()
+ error = e.message ?: "Could not read that folder"
+ }
+ loading = false
+ }
+
+ // Back — gesture or button — walks UP one folder and only leaves the screen
+ // from the top. Registered here rather than in the caller (as Notes and
+ // Settings do) precisely because it is not a plain dismiss: the caller does
+ // not know how deep the browse is.
+ BackHandler {
+ if (stack.isNotEmpty()) stack = stack.dropLast(1) else onBack()
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)
+ // targetSdk 36 makes edge-to-edge mandatory, and nothing in this app
+ // compensates — so without this the header draws UNDER the status
+ // bar: the back arrow lands behind the clock, where it is hard to
+ // see and hard to hit (a synthetic tap on it is swallowed
+ // outright). The background is applied before the padding so the
+ // bar still sits on our colour rather than a bare gap.
+ .systemBarsPadding(),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // Same action as the back gesture, so the two never disagree about
+ // what "back" means at a given depth.
+ IconButton(onClick = { if (stack.isNotEmpty()) stack = stack.dropLast(1) else onBack() }) {
+ Icon(
+ Icons.AutoMirrored.Outlined.ArrowBack,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ Text(
+ title,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+
+ if (downloading != null) {
+ Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
+ Text(
+ "Downloading ${downloading}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.size(6.dp))
+ // Indeterminate when the laptop did not report a size: a bar
+ // stuck at 0% would read as broken.
+ if (progress >= 0f) {
+ LinearProgressIndicator(
+ progress = { progress },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ } else {
+ LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
+ }
+ }
+ }
+
+ when {
+ loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
+ error != null -> Box(
+ Modifier.fillMaxSize().padding(32.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ error!!,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ entries.isEmpty() -> Box(
+ Modifier.fillMaxSize().padding(32.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ "This folder is empty",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
+ items(entries, key = { it.path.ifEmpty { it.name } }) { e ->
+ EntryRow(e) {
+ if (e.isDir) {
+ stack = stack + (e.path to e.name)
+ } else if (downloading == null) {
+ downloading = e.name
+ progress = if (e.size > 0) 0f else -1f
+ scope.launch {
+ val dest = File(
+ Environment.getExternalStoragePublicDirectory(
+ Environment.DIRECTORY_DOWNLOADS,
+ ),
+ uniqueName(e.name),
+ )
+ val msg = try {
+ FsClient.download(e.path, dest) { done, total ->
+ progress = if (total > 0) {
+ (done.toDouble() / total).toFloat()
+ } else {
+ -1f
+ }
+ }
+ "Saved to Downloads/${dest.name}"
+ } catch (ex: FsClient.FsException) {
+ explain(ex)
+ } catch (ex: Exception) {
+ ex.message ?: "Download failed"
+ }
+ downloading = null
+ Toast.makeText(context, msg, Toast.LENGTH_LONG).show()
+ }
+ }
+ }
+ HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun EntryRow(e: FsEntry, onClick: () -> Unit) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ if (e.isDir) Icons.Outlined.Folder else Icons.Outlined.Description,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(22.dp),
+ )
+ Spacer(Modifier.width(14.dp))
+ Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.Center) {
+ Text(
+ e.name,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ if (!e.isDir) {
+ Text(
+ humanSize(e.size),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+}
+
+/** Say what actually went wrong. The codes are errno-shaped, so each one has a
+ * true sentence — "not permitted" and "the laptop did not answer" send the
+ * user to completely different places. */
+private fun explain(e: FsClient.FsException): String = when (e.code) {
+ FsCode.NOENT -> "That file is no longer there"
+ FsCode.ACCES -> "The laptop is not sharing that folder"
+ FsCode.NOTSUP -> "The laptop does not support that"
+ FsCode.ISDIR -> "That is a folder"
+ FsClient.TIMEOUT -> "The laptop did not answer — is it awake and in range?"
+ else -> "Could not read that (${e.message})"
+}
+
+private fun humanSize(bytes: Long): String = when {
+ bytes < 1024 -> "$bytes B"
+ bytes < 1024 * 1024 -> "%.0f KB".format(bytes / 1024.0)
+ bytes < 1024L * 1024 * 1024 -> "%.1f MB".format(bytes / (1024.0 * 1024))
+ else -> "%.2f GB".format(bytes / (1024.0 * 1024 * 1024))
+}
+
+/** Never overwrite something already in Downloads: append " (n)" like every
+ * browser does, so a second pull of the same name is not a silent loss. */
+private fun uniqueName(name: String): String {
+ val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
+ if (!File(dir, name).exists()) return name
+ val dot = name.lastIndexOf('.')
+ val stem = if (dot > 0) name.substring(0, dot) else name
+ val ext = if (dot > 0) name.substring(dot) else ""
+ var i = 1
+ while (File(dir, "$stem ($i)$ext").exists()) i++
+ return "$stem ($i)$ext"
+}
diff --git a/android/app/src/main/java/com/vortex/a3/ui/screens/SettingsScreen.kt b/android/app/src/main/java/com/vortex/a3/ui/screens/SettingsScreen.kt
index 0dc4f1e..9adc4c1 100644
--- a/android/app/src/main/java/com/vortex/a3/ui/screens/SettingsScreen.kt
+++ b/android/app/src/main/java/com/vortex/a3/ui/screens/SettingsScreen.kt
@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
@@ -29,6 +30,7 @@ import androidx.compose.material.icons.outlined.DarkMode
import androidx.compose.material.icons.outlined.FileDownload
import androidx.compose.material.icons.outlined.FolderOpen
import androidx.compose.material.icons.outlined.Movie
+import androidx.compose.material.icons.outlined.Storage
import androidx.compose.material.icons.outlined.Headset
import androidx.compose.material.icons.outlined.Language
import androidx.compose.material.icons.outlined.LightMode
@@ -100,10 +102,22 @@ fun SettingsScreen(
onPickSharedFolder: () -> Unit,
screenControlOn: Boolean,
onScreenControlClick: () -> Unit,
+ allFilesOn: Boolean,
+ onAllFilesClick: () -> Unit,
onBack: () -> Unit,
) {
Column(
- modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background),
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)
+ // targetSdk 36 makes edge-to-edge mandatory: the app draws behind
+ // the status bar whether it asks to or not, so this header sat in
+ // the same band as the clock, where the system consumes the touch.
+ // The back arrow rendered fine and simply did not respond, which
+ // reads as a broken button rather than a mispositioned one.
+ // Background BEFORE padding, so the status bar still sits on our
+ // colour instead of a bare strip.
+ .systemBarsPadding(),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp),
@@ -302,6 +316,20 @@ fun SettingsScreen(
status = if (screenControlOn) "On" else "Off",
onClick = onScreenControlClick,
)
+ ActionRow(
+ icon = Icons.Outlined.Storage,
+ title = "Allow access to any files",
+ // Says what it costs before it is granted, and what it
+ // replaces once it is: with all-files on, the picked
+ // folders are superseded rather than added to, and showing
+ // the same file under two paths would be worse than saying
+ // so here.
+ hint = if (allFilesOn)
+ "On — the laptop can browse all of your storage, read-only"
+ else "Off — instead of picking folders, share everything (asks Android)",
+ status = if (allFilesOn) "On" else "Off",
+ onClick = onAllFilesClick,
+ )
}
}
}
diff --git a/docs/communication/README.md b/docs/communication/README.md
new file mode 100644
index 0000000..87a34b9
--- /dev/null
+++ b/docs/communication/README.md
@@ -0,0 +1,355 @@
+# Vortex communication scheme
+
+How a phone and a laptop find each other, authenticate, and exchange data.
+Reflects the code as of `feat/allow-multiple-peering`.
+
+Everything here is peer-to-peer. There is no server, no relay, and no cloud
+account anywhere in these paths.
+
+---
+
+## 1. Roles are fixed
+
+The two devices are **not** symmetric, and several design decisions follow from
+that. The phone advertises and serves; the laptop scans and connects.
+
+```mermaid
+graph LR
+ subgraph Phone["📱 Phone (Android)"]
+ direction TB
+ ADV["BLE peripheral advertiser"]
+ GS["GATT server service 53ffc983…"]
+ LS["LAN server TCP :51820 + mDNS"]
+ end
+ subgraph Laptop["💻 Laptop (Linux)"]
+ direction TB
+ SCAN["BLE central scanner"]
+ GC["GATT client"]
+ LC["LAN client mDNS resolver"]
+ end
+ ADV -. "ADV_IND (service data)" .-> SCAN
+ GC == "connect + GATT" ==> GS
+ LC == "TCP connect" ==> LS
+```
+
+The laptop never advertises and the phone never scans. So "make yourself
+findable" is always the phone's job, which is why the whole multi-peer design
+turns on *what the phone advertises* rather than on discovery symmetry.
+
+---
+
+## 2. Discovery — BLE advertisement
+
+One legacy `ADV_IND`, already at the 31-byte ceiling: a 3-byte Flags AD plus a
+28-byte Service Data AD (16-byte UUID + 10-byte payload). There is no room for
+a second payload field.
+
+```
+Service Data (10 bytes)
+┌────────┬────────┬──────────────────────────────┐
+│ ver=01 │ flags │ payload_8 (8 bytes) │
+└────────┴────────┴──────────────────────────────┘
+ │ │
+ bit0 PAIRABLE ──────────► random pairing-window instance ID
+ bit1 TRUSTED_PRESENCE ──► rotating presence token
+```
+
+`is_well_formed()` requires reserved bits 2–7 zero and **exactly one** of
+bit0/bit1 — mirrored in Rust and Kotlin. A third mode bit would be rejected as
+malformed by every deployed peer, which is why "seeking" is a local power state
+and not a wire flag.
+
+The presence token is per-peer, rotating hourly-ish by 60 s buckets:
+
+```
+token = HMAC-SHA256(peer.prs, "vortex/v1/presence" ‖ u64_be(bucket))[0..8]
+bucket = unix_seconds / 60
+```
+
+The laptop accepts the current bucket **±2** (clock skew, Doze-deferred
+rotations). The token is a privacy / anti-DoS filter, **not** authentication —
+Noise IK still gates trust. The same construction names the mDNS instance, so
+one mechanism covers both transports.
+
+### Phone advertising states
+
+```mermaid
+stateDiagram-v2
+ [*] --> Pairable: peer list empty or "Pair another laptop"
+ Pairable --> Active: pairing completes
+ Active --> Seeking: link lost or "Switch laptop"
+ Seeking --> Active: a laptop connects
+ Seeking --> Dark: ladder exhausted
+ Dark --> Seeking: screen on + app foreground
+ Active --> Pairable: "Pair another laptop"
+
+ note right of Active
+ advertises NOTHING
+ the session is the presence proof
+ end note
+ note right of Seeking
+ TRUSTED_PRESENCE
+ LOW_LATENCY → BALANCED → LOW_POWER
+ tokens multiplexed across peers
+ end note
+ note right of Dark
+ still advertises (BALANCED)
+ silence would break proximity lock
+ end note
+```
+
+Two constraints shape this:
+
+- **`Active` = silent** is the main battery win, and it is safe because the
+ laptop's proximity auto-lock treats *session OR advertisement* as presence.
+ Measured resume after a real disconnect: **16 ms** to back on air, against a
+ 25 s away-grace and a 2 s confirmation scan.
+- **`Dark` still advertises.** Going fully silent would make a present-but-idle
+ phone look absent to that same confirmation scan, and would break walk-up
+ reconnect.
+
+With several remembered laptops the single advertising set **time-multiplexes**
+tokens (~1.5 s dwell each), because one advertisement cannot address N peers.
+With one peer it does not cycle at all — restarting the advertiser churns the
+RPA for nothing.
+
+---
+
+## 3. Channels
+
+```mermaid
+graph TB
+ subgraph BLE["BLE — signalling, always available"]
+ CAP["Capability · READ capability flags"]
+ PC["Pairing Control · WRITE + NOTIFY Noise XX + SAS"]
+ RC["Reconnect Control · WRITE + NOTIFY Noise IK + ping/pong"]
+ AS["Audio Signal · WRITE(+NR) + NOTIFY sealed app-data frames"]
+ end
+ subgraph LAN["LAN — bulk, when reachable"]
+ MD["mDNS _vortex._tcp.local. instance vortex-(16 hex)"]
+ TCP["TCP :51820 Noise IK → sealed frames"]
+ end
+ subgraph FAST["Opportunistic"]
+ WD["Wi-Fi Direct 5 GHz P2P, ~20 MB/s"]
+ MIR["Mirror: TCP control + UDP video"]
+ ADB["adb — screen features only"]
+ end
+ BLE --> LAN --> FAST
+```
+
+Despite the name, **Audio Signal is the general sealed app-data channel** — it
+carries notifications, clipboard, SMS, call log, notes, file offers and the
+handoff frames, not just audio. The name is historical.
+
+### Frame format
+
+```
+┌───┬─────┬────────────┬─────────────────────┐
+│ty │ sub │ len u16 BE │ payload (≤ 63 KiB) │
+└───┴─────┴────────────┴─────────────────────┘
+ 0 1 2 4
+```
+
+Everything after the handshake is AEAD-sealed with the Noise transport ciphers.
+Frame types are a flat `u8` registry mirrored byte-for-byte between
+`core/ble/frame.rs` and `Frame.kt`; drift there is a protocol break, so both
+files carry that warning.
+
+| Range | Purpose |
+|---|---|
+| `0x10–0x12` | pairing handshake / approval |
+| `0x20–0x21` | reconnect handshake |
+| `0x30–0x3F` | keepalive, app-state, notifications, icons, calls, contacts, SMS, call log, bulk-sync |
+| `0x40–0x4F` | clipboard, Wi-Fi Direct, file push, browsing handoff, notes, FRAG, **peer handoff** |
+| `0x7F` | error |
+
+Unknown frame types are **logged and ignored** on both sides. That is what makes
+new types additive: an older peer is unaffected, so no version gate is needed.
+
+`FRAG` (`0x4E`) exists because Android silently truncates a notify longer than
+`ATT_MTU−3`. The sender splits the *already-sealed* frame into fragments; the
+inner frame keeps one nonce for the whole logical frame.
+
+> **Note on `shared/proto/vortex.proto`:** it describes the protocol at spec
+> level, but the live BLE/LAN transport does **not** use `VortexMessage`. It
+> uses the `Frame` format above. When adding a message, the frame registry is
+> the thing that changes behaviour.
+
+---
+
+## 4. Pairing (first contact)
+
+```mermaid
+sequenceDiagram
+ participant P as 📱 Phone
+ participant L as 💻 Laptop
+ Note over P: user opens pairing window flags = PAIRABLE
+ P-->>L: ADV_IND · pairable · instance ID
+ Note over L: radar shows the device (pairable hits only)
+ L->>P: GATT connect
+ L->>P: Pairing Control ← Noise XX msg1
+ P->>L: NOTIFY → msg2
+ L->>P: Pairing Control ← msg3
+ Note over P,L: SAS = 3 emoji derived from the transcript hash
+ Note over P,L: user compares on both screens
+ L->>P: approve
+ P->>L: approve
+ Note over P,L: trust stored: peer_static_pub + PRS keyring (laptop) / EncryptedSharedPrefs (phone)
+ Note over P: hands radio to VortexService flags → TRUSTED_PRESENCE
+```
+
+The SAS comparison is the MITM defence, and it happens **before** any BT-level
+bond. Vortex deliberately creates **no BT bond on Linux** — LE bonding was
+investigated and abandoned (BlueZ routes bonds over BR/EDR on dual-mode phones,
+Just Works fails, no IRK). Reconnect is bondless: learn-latest-RPA plus a
+presence scan.
+
+A stale one-sided bond — one side holding a bond the other dropped — makes the
+link tear down during encryption, so `ServicesResolved` never arrives and
+pairing fails with `timeout: service discovery`. Forget now clears the bond on
+both sides for exactly this reason.
+
+---
+
+## 5. Reconnect and steady state
+
+```mermaid
+sequenceDiagram
+ participant P as 📱 Phone
+ participant L as 💻 Laptop
+
+ rect rgb(240, 245, 255)
+ Note over L: BLE path
+ P-->>L: ADV_IND · trusted-presence · token
+ Note over L: token matches a trusted peer's current bucket ±2
+ L->>P: connect (last-known RPA first, else scan)
+ L->>P: Reconnect Control ← Noise IK msg1 + counter
+ P->>L: msg2 + peer counter
+ L->>P: ping → pong
+ Note over P,L: session live · counters bumped
+ end
+
+ rect rgb(240, 255, 245)
+ Note over L: LAN path (parallel, independent)
+ P-->>L: mDNS announce · vortex-(token)
+ L->>P: TCP :51820 + Noise IK
+ L->>P: bulk-sync request (per-dataset hashes + watermarks)
+ P->>L: only what differs: contacts / sms / call_log / histories
+ loop every ~12 s
+ L->>P: AppState heartbeat
+ P->>L: AppState (battery, earbuds, locked, media)
+ end
+ end
+```
+
+Both transports run **independently and concurrently**. BLE gives fast
+signalling that works with no network at all; LAN gives throughput. Either
+alone is a working link, which is why the phone can read "connected" over BLE
+on a Wi-Fi network with AP isolation where the LAN heartbeat never completes.
+
+A cross-transport hint links them: the LAN heartbeat's down→up edge nudges the
+BLE loop to retry its direct connect immediately instead of waiting out a scan
+backoff.
+
+Bulk-sync is diff-based — the laptop sends what it already has, the phone sends
+only the delta, and each dataset reports `match` / `sent` / `error`
+independently so one failing dataset cannot kill the connection.
+
+---
+
+## 6. Session ownership — one active peer
+
+A device may **trust** many peers but is **active** with exactly one. Those are
+deliberately separate: several transport links may briefly overlap during a
+handoff, but only one peer owns the mirrored state (notifications, clipboard,
+SMS pages, media). Without the split, connecting to a replacement before the old
+link finished dropping would give two devices ownership at once.
+
+```mermaid
+sequenceDiagram
+ participant A as 💻 Laptop A (current)
+ participant P as 📱 Phone
+ participant B as 💻 Laptop B (target)
+
+ Note over P: user taps "Switch laptop"
+ Note over P,A: link to A is HELD — nothing dropped yet
+ P-->>B: TRUSTED_PRESENCE (A excluded from the multiplexed token set)
+ B->>P: connect + Noise IK
+ Note over P,B: ownership moves to B
+ P->>A: PEER_HANDOFF · RELEASE · successor name
+ Note over A: drop ownership, purge cached pages, clear "active" on the card
+ Note over P,A: A's transport link drops on its own
+```
+
+**Seek before release** is the key property: the current link is held until a
+replacement is confirmed. So a cancelled or fruitless switch leaves the device
+exactly where it was, and there is no window in which it is connected to
+nothing — which also means no suppression window is needed to stop the old
+peer's reconnect loop racing back.
+
+`PEER_HANDOFF` (`0x4F`) kinds: `RELEASE` (0x01), `BUSY` (0x02), `CLAIM` (0x03).
+`BUSY` exists so a refused peer can back off — silence is indistinguishable from
+packet loss and invites a retry loop against the phone's single GATT link.
+
+The mirror direction is identical: when the laptop switches phones, it sends
+`RELEASE` to the phone it displaced.
+
+**Current limitation:** `RELEASE` rides the BLE sealed channel only. A switch
+that happens with only a LAN session up will not deliver it, and the peer falls
+back to noticing on next contact.
+
+---
+
+## 7. Opportunistic transports
+
+**Wi-Fi Direct** — for bulk file transfer the phone creates a 5 GHz P2P group
+and the laptop joins it via `nmcli`, pulls at ~20 MB/s, then restores its normal
+Wi-Fi. Single adapter, so the laptop is briefly offline; the heartbeat targets
+the group-owner IP while active.
+
+**Screen mirroring** splits control from data: TCP carries control, **UDP
+carries video**. TCP head-of-line blocking plus retransmit is exactly the
+freeze-then-jump artefact to avoid. Security is kept without Noise's strict
+in-order nonce by deriving a media key from the IK handshake hash and sealing
+each datagram with ChaCha20-Poly1305 under an explicit per-packet counter,
+guarded by a sliding replay window.
+
+**adb** is used only for the screen features (Universal Control, second screen),
+because writing to `/dev/uinput` requires the shell user. It carries no Vortex
+protocol traffic.
+
+---
+
+## 8. Per-peer state
+
+Secrets are keyed by `peer_static_pub` on both sides — PRS, reconnect counters,
+audio nonces, the peer's BT address.
+
+Cached phone data on the laptop is namespaced by public key, not by name (a
+peer-supplied display name is untrusted, collides, and changes):
+
+```
+~/.cache/vortex/peers//{sms,contacts,call_log,…}.json
+```
+
+Deliberately **global**, because they are one shared list by design:
+`notes.json` and clipboard history.
+
+---
+
+## 9. Security properties in one place
+
+| Property | Mechanism |
+|---|---|
+| MITM defence at pairing | Noise XX + 3-emoji SAS compared by the user |
+| Reconnect authentication | Noise IK, PRS as prologue |
+| Replay / rollback detection | monotonic per-peer counters exchanged in IK |
+| Confidentiality of app data | AEAD-sealed frames on the Noise transport |
+| Advertisement unlinkability | 8-byte token rotating per 60 s bucket, per peer |
+| Revocation | forgetting deletes the PRS, so its token can no longer be derived |
+| Video data plane | media key from the IK hash, per-packet counter + replay window |
+
+Per-peer tokens (rather than one shared device key) are what make revocation
+clean: a forgotten peer's token is not merely rejected, it becomes
+uncomputable, so that device is exactly as able to find the phone as a
+stranger's — not at all.
diff --git a/docs/design/file-browsing.md b/docs/design/file-browsing.md
new file mode 100644
index 0000000..f44a735
--- /dev/null
+++ b/docs/design/file-browsing.md
@@ -0,0 +1,504 @@
+# Browsing the phone's files from the desktop
+
+**Status:** built through §8 step 2, plus step 6 (the mounts) on both OSes.
+Steps 3-5 — the daemon cache layer, a WebDAV gateway, writes — are open; the
+gateway is now unlikely, see §4. **Targets:** Linux *and* Windows from day one.
+Linux is verified on a device; the Windows adapter compiles and has never run
+(§8 step 6).
+
+Goal: open the phone's storage in Dolphin / Nautilus / Explorer, like KDE
+Connect does. Read-only first, writes stubbed.
+
+---
+
+## 1. Why this is the same work as fixing large-file transfer
+
+There is exactly one missing primitive underneath both features:
+
+```
+READ(handle, offset, len) -> bytes
+```
+
+- **Browsing** needs it because file managers issue ranged reads constantly —
+ Explorer's redirector does, thumbnailers do, media players seek.
+- **Large-file transfer** needs it because the current design buffers whole
+ files, which is what crashed the app on an 835 MB share (`OutOfMemoryError`,
+ 876 MB against a 256 MB heap growth limit).
+
+Today there is **no offset-based read anywhere** in the codebase, and the
+Android app declares **no storage permissions at all** — file access is only
+ever a `content://` URI handed over by the share sheet. So both features start
+from the same standing start, and the 64 MB `MAX_FILE_BYTES` cap disappears as a
+side effect of building the primitive rather than as a separate change.
+
+**Corollary:** do not raise `MAX_FILE_BYTES` in the meantime. It is bounded by
+the process heap, so a bigger constant only moves the crash.
+
+---
+
+## 2. Layering
+
+The load-bearing decision: **the phone serves a dumb, narrow protocol; the
+laptop does everything clever.**
+
+```mermaid
+graph TB
+ subgraph Desktop["💻 Desktop"]
+ FM["Dolphin / Nautilus / Explorer"]
+ MNT["Mount adapter (per-OS, swappable)"]
+ CACHE["Vortex daemon metadata cache · content cache readahead · coalescing"]
+ end
+ subgraph Phone["📱 Phone"]
+ FS["File provider SAF / MediaStore"]
+ end
+ FM -->|loopback| MNT
+ MNT --> CACHE
+ CACHE -->|"LIST · STAT · READ(off,len) over the existing Noise session"| FS
+```
+
+Three consequences worth stating explicitly:
+
+**All caching lives in the daemon.** The Android app answers ranged reads and
+nothing more — no cache, no prefetch, no invalidation logic. Android is the
+worst place for that code: process death, Doze, and low-memory kills make cache
+lifetime unpredictable, and every cache bug would need a phone rebuild to test.
+
+**The phone never serves the LAN.** The daemon exposes the mount on
+**loopback only** and proxies over the already-authenticated Noise session. This
+reuses pairing as the auth model — no second credential system, no TLS on the
+phone, no listening socket exposed to the network, and free choice of port.
+
+**The mount adapter is swappable; the protocol is the investment.** Changing
+how the desktop presents the files must never require touching the phone.
+
+---
+
+## 3. The protocol
+
+New frame types, additive (unknown types are logged and ignored on both sides,
+so no version gate is needed). Rides the existing sealed app-data channel.
+
+| Op | Direction | Payload | v1 |
+|---|---|---|---|
+| `FS_LIST` | laptop → phone | path / tree handle, cursor | ✅ |
+| `FS_STAT` | laptop → phone | path | ✅ |
+| `FS_READ` | laptop → phone | handle, offset, len | ✅ |
+| `FS_WRITE` | laptop → phone | handle, offset, bytes | **stub** |
+| `FS_SETMETA` | laptop → phone | path, mtime / mode / rename | **stub** |
+| `FS_DATA` | phone → laptop | request id, offset, bytes, eof | ✅ |
+| `FS_META` | phone → laptop | entries / stat result | ✅ |
+| `FS_ERR` | phone → laptop | request id, code | ✅ |
+
+Stub means: **defined, wired, and answered with a clear `FS_ERR` "not
+supported"** — not silently dropped. A stub that looks like a timeout is worse
+than an honest refusal, and the file manager needs a definite answer to avoid
+hanging.
+
+Design notes:
+
+- **Request IDs, not a request/response lock.** File managers issue many
+ concurrent stats; a strictly serialised protocol would feel broken. Cap
+ in-flight requests (the phone's link is not infinitely parallel) and pipeline
+ the rest.
+- **Reads are bounded per frame.** Existing `MAX_FRAME_PAYLOAD` is 63 KiB; the
+ daemon issues many ranged reads rather than one huge one. That is what keeps
+ memory flat on both sides.
+- **Directory listings paginate.** A 10,000-entry folder must not be one frame.
+- **Handles, not paths, for reads.** A path resolved per read is a TOCTOU
+ problem and slow under SAF; open once, read many, close.
+
+---
+
+## 4. Desktop presentation: native VFS on both, WebDAV never built
+
+***Both* operating systems went straight to the native VFS (v2); WebDAV was
+skipped entirely.** Its whole argument was being the cheapest path to something
+usable, and on neither OS did that survive contact:
+
+* **Linux** — GVFS and KIO mount `davs://` *inside the file manager's own
+ process*, so only that program's file dialogs would see the files. `cp`,
+ `mpv`, `ffprobe`, a text editor's Open box: none of them. FUSE is a real path
+ in the filesystem, so everything sees it.
+* **Windows** — the WebClient redirector caps a file at ~50 MB by default, and
+ escaping a 64 MB cap into a 50 MB one would have been absurd. ProjFS has no
+ such ceiling and needs no third-party install.
+
+What v1 was really buying was *one* implementation instead of two. That turned
+out to be the wrong unit of accounting: the two native adapters share
+everything above the OS call (`fs_vfs.rs` — caching, the path walk, pipelined
+reads, the concurrency cap) and differ only in the translation layer, which a
+WebDAV gateway would have needed anyway in the form of an HTTP server. Two
+adapters over one shared core came to about the same code as one gateway, with
+no size limits, no port, no auth story and no second process.
+
+The v1 sketch is kept below because its Windows caveat table is the reason
+ProjFS won there, and because it is the road not taken.
+
+### v1 — WebDAV on loopback (not built)
+
+One implementation serving both OSes:
+
+- **Linux:** `davs://localhost:PORT` via GVFS (Nautilus) / KIO (Dolphin).
+- **Windows:** `\\localhost@PORT\DavWWWRoot\` via the WebClient redirector.
+
+Cheapest path to something usable, and platform-neutral Rust in the daemon.
+
+**Windows WebDAV caveats — plan for these, they are not hypothetical:**
+
+| Issue | Detail |
+|---|---|
+| `FileSizeLimitInBytes` | WebClient defaults to ~**50 MB**. Escaping a 64 MB cap into a 50 MB one would be absurd — needs a registry change or an installer step |
+| Basic auth over HTTP | Disabled by default (`BasicAuthLevel`). Avoidable by requiring **no auth on loopback** — nothing but local processes can reach it |
+| WebClient service | Must be running; Explorer's WebDAV client is slow and flaky under load |
+| Port syntax | Non-standard ports need the `\\host@port\` form, which is unfamiliar to users |
+
+Loopback-only binding removes the auth problem outright. The 50 MB limit does
+not go away and is the main reason v1 may not be the end state.
+
+### v2 — native virtual filesystem — **both done**
+
+- **Linux:** FUSE ([`fs_fuse.rs`]), mounted at `$XDG_RUNTIME_DIR/vortex/phone`.
+- **Windows:** **ProjFS** ([`fs_projfs.rs`]), projected at
+ `%LOCALAPPDATA%\Vortex\phone`. Ships in Windows 10 1809+ with no
+ third-party install — it is what VFS for Git uses — though it is an optional
+ feature that is off by default on client SKUs, so the mount error says how to
+ turn it on.
+
+Three files, not two: [`fs_vfs.rs`] is everything between an adapter and the
+wire — the caches, the path walk, the pipelined ranged reads, the concurrency
+cap — and the adapters are only translation. That is what keeps "the mount
+adapter is swappable; the protocol is the investment" true in the code and not
+just in this document: adding Windows touched nothing the phone can observe.
+
+**The threading models are opposites, and that is the whole difference.**
+
+| | FUSE | ProjFS |
+|---|---|---|
+| Request delivery | one at a time, one session thread | its own thread pool, concurrent |
+| So the adapter must | **never block** — hand every op to the async runtime and answer from there | **block freely** — that is what the pool is for |
+| Concurrency limited by | our semaphore | our semaphore (the pool is sized above it on purpose) |
+
+Blocking a FUSE session thread serialises the entire mount behind one round
+trip at a time. Blocking a ProjFS pool thread is what ProjFS is built for — so
+the Windows callbacks are plainly synchronous, and the pool is sized at twice
+`MAX_INFLIGHT` so the shared semaphore runs out first and a thundering
+thumbnailer cannot exhaust the pool and wedge Explorer. If that ever stops
+holding, ProjFS has its own escape hatch (`ERROR_IO_PENDING` plus
+`PrjCompleteCommand`); it costs an owned copy of every callback parameter,
+which is why it is not the starting point.
+
+**Read-only is enforced differently too.** FUSE takes an `ro` mount option and
+the kernel refuses writes before they reach us. ProjFS has no such flag, so the
+projection marks every placeholder `FILE_ATTRIBUTE_READONLY` (advisory — it
+greys the commands out in Explorer) *and* vetoes `PRE_DELETE`, `PRE_RENAME`,
+`PRE_SET_HARDLINK` and `FILE_PRE_CONVERT_TO_FULL` from the notification
+callback, which is the half that actually enforces it.
+
+**One thing ProjFS gives free and one it costs.** It hydrates fetched content
+into the real directory and serves later reads from disk without asking us —
+design doc §7's content cache, for nothing. The cost is staleness: a file that
+changes on the phone is not re-fetched. The projection is therefore cleared at
+each mount, so a session starts from the phone's current truth; *within* a
+session a changed file still shows its old content. Fixing that properly means
+deriving a placeholder ContentID from size and mtime and driving
+`PrjUpdateFileIfNeeded` — the natural companion to step 3.
+
+### Rejected: SFTP + sshfs
+
+What KDE Connect uses, and excellent on Linux. On Windows it needs WinFsp +
+SSHFS-Win — a third-party install we would be asking every user to do. Out on
+the cross-platform requirement alone.
+
+### Rejected: SMB
+
+Explorer's best-supported protocol, but the Windows client effectively requires
+port 445, which Android cannot bind (privileged port, no root), and Android SMB
+server implementations are heavy. Non-starter.
+
+---
+
+## 5. Android file access — a decision to make
+
+There is no storage permission today, so this is new surface either way:
+
+| Option | Gets you | Costs |
+|---|---|---|
+| **SAF trees** (`ACTION_OPEN_DOCUMENT_TREE`) | user grants specific folders | content URIs rather than paths, slower enumeration, no whole-device view |
+| **`MANAGE_EXTERNAL_STORAGE`** | full filesystem, the KDE Connect experience | alarming permission dialog; Play-Store-restricted (not binding — Vortex ships via GitHub releases) |
+
+**Recommendation:** SAF trees as the default, all-files access as an explicit
+opt-in for users who want the full view. That keeps the scary permission out of
+the first-run path while not capping what power users can do.
+
+*Implemented as recommended.* "Shared folders" runs the SAF picker; "Allow
+access to any files" opens Android's special-access screen and is never touched
+on the first-run path. Neither grant is mirrored into a preference of ours — the
+OS grant IS the setting, read live, so a revocation in system settings cannot
+leave us offering a root we can no longer read.
+
+Two consequences worth knowing:
+
+* All-files **supersedes** the picked folders rather than adding to them.
+ Serving both would show one file under two unrelated paths, and nothing on
+ the wire says they are the same file.
+* Under all-files the served root is shared storage only, still canonicalised
+ and gated. Being granted all-files is not agreement to serve `/data`: the
+ user turned on "any files" meaning *their* files, and the app can read a
+ great deal more than that.
+
+---
+
+## 6. Transport reality
+
+**Content streams over Wi-Fi.** BLE is tens of KB/s — unusable for file bytes,
+and the moment a file is more than trivial the user will turn Wi-Fi on anyway.
+
+BLE stays useful for **metadata and wake-up**: a directory listing or a stat can
+ride it, and it is how the daemon knows the phone is there at all. So:
+
+- Wi-Fi (LAN, or Wi-Fi Direct for bulk) is required for content.
+- With no usable network, the mount reports an honest, immediate error rather
+ than hanging — a file manager blocked on a dead read is the worst outcome.
+ *Done:* a request that reaches neither transport fails at once with
+ `EHOSTDOWN` — or `ERROR_HOST_DOWN`, which Explorer renders as "The host is
+ down" — instead of waiting out the 20 s reply timeout. Twenty seconds per
+ operation on a phone that is simply not here is indistinguishable from a hung
+ file manager. Both adapters map the protocol's codes straight across, which
+ is what the errno shape in §3 was for.
+- Wi-Fi Direct is already used for large transfers and applies here unchanged.
+
+---
+
+## 7. What makes this feel fast or broken
+
+This is where these features usually fail, and it is all daemon-side:
+
+- **Metadata cache with invalidation.** File managers stat everything in view,
+ repeatedly. Without a cache, every icon refresh is a round trip. *Done for
+ the mount, mostly by the kernel:* attributes and directory entries carry a
+ 5 s TTL, so a repeat `stat` inside that window never even reaches our
+ process. The other half is ours — a listing seeds the attribute cache for
+ every entry in it, which is what makes the `lookup` + `getattr` storm that
+ follows a `readdir` cost nothing. Invalidation is the TTL expiring; there is
+ no push notification of a change on the phone, and 5 s is the compromise.
+- **Readahead.** Sequential reads (copying, media playback) should pull ahead of
+ the requested range; a strict 63 KiB request/response ping-pong will never
+ saturate Wi-Fi. *Partly done:* both clients keep 4 ranged reads in flight,
+ which turns the round trip from a per-chunk cost into an overlapped one —
+ 2.07 to 8.1 MB/s laptop-side, 0.4 to 2.3 MB/s phone-side. Reading *ahead* of
+ what was asked for arrives with the mount, and again from the kernel rather
+ than from us: a sequential reader makes the kernel issue several `read` calls
+ at once, and because the mount answers every one off-thread instead of
+ blocking, they overlap on the wire. A daemon-side readahead of its own is
+ still open, and is what would help the *first* read of a file.
+- **Coalescing and a concurrency cap.** Thumbnailers fire dozens of parallel
+ reads; unbounded, they will starve the link and the BLE session with it. *Cap
+ done:* the mount holds a semaphore of 8 over every request it sends, so a
+ folder of photos cannot queue megabytes of image data ahead of the next
+ listing. Coalescing overlapping ranges is not done.
+- **Content cache with a byte budget**, not an entry count — one 2 GB video must
+ not evict a whole tree's metadata.
+- **Honest errors.** Every failure path returns a definite error quickly.
+ Hanging is worse than failing.
+
+---
+
+## 8. Sequencing
+
+1. **`FS_STAT` + `FS_LIST` + `FS_READ`** on the phone (answer ranged reads,
+ nothing else) and the daemon-side client. No mount yet — validate over the
+ existing session with a CLI. **Done and validated on a device**
+ (2026-09-06, over BLE, all-files root): `--fs-ls` returned 34 entries of
+ shared storage; `--fs-stat` matched size and mtime; `--fs-get` fetched
+ 28 KB and 482 KB files byte-identical by md5, the latter over 11 ranged
+ reads with the zip still passing `unzip -t`. Refusals behave: a missing path
+ inside a root answers NOENT, while `/data/...`, `/etc/hosts` and a SAF URI
+ under all-files all answer ACCES, so a peer cannot probe outside what is
+ served.
+
+ Two bugs it caught, both pre-existing and neither specific to this feature:
+ fragments were sized at `MTU-3` while GATT caps an attribute value at 512
+ and *throws* above it, so fragmenting crashed the app on a 517-MTU link; and
+ `init_logging` rolled the log on every forwarding CLI launch, destroying the
+ running app's file.
+
+ Wi-Fi is now the preferred transport, with BLE as the fallback (§6). Same
+ 482 KB file, same phone, same session: **41 KiB/s over BLE, 931 KiB/s over
+ LAN** — 23x — and a directory listing went from ~2.5 s to 11 ms. Both
+ byte-identical. The gain is mostly framing: a BLE notify caps at 512 bytes,
+ so a 48 KiB read is ~96 fragments paced 10 ms apart, against one TCP frame.
+
+ The LAN session is opened lazily, kept for 60 s of idleness, and both
+ transports serve from ONE handle table on the phone — a handle minted by
+ OPEN over Wi-Fi must still be readable by a READ that fell back to BLE.
+2. **Rework large-file transfer onto ranged reads.** Removes `MAX_FILE_BYTES`
+ and the buffer-the-whole-file crash. Ships value before any mount exists.
+ **Done.** A share now registers a *grant* (a URI plus a random token) instead
+ of reading the file, and the laptop pulls it through `FS_OPEN`/`FS_READ`/
+ `FS_CLOSE` straight to disk. `MAX_FILE_BYTES` is deleted on both sides.
+ Verified on the device with a 151 MB APK — 2.4x the old cap, so previously
+ refused outright: byte-identical in 73 s, and the phone's Java heap stayed at
+ 16-23 MB throughout, where the old path would have had to hold all 151 MB.
+
+ A share-sheet file is authorised differently from a browsed one, so it gets
+ its own gate ([`ShareGrants`]): the act of sharing IS the authorisation,
+ scoped to that one file, addressed by an unguessable token, revoked when the
+ laptop closes it. It is not, and cannot become, a root.
+3. **Daemon cache layer** — metadata, readahead, content budget.
+4. **WebDAV loopback gateway**, both OSes.
+5. **`FS_WRITE` / `FS_SETMETA`** for real, once read-only is solid.
+6. **FUSE + ProjFS**, if the Windows WebDAV limits bite. **Both done**, ahead
+ of steps 3-5 and instead of WebDAV on either OS — see §4. `--fs-mount` /
+ `--fs-umount`, or the folder button on the phone's card, put the phone's
+ storage at `$XDG_RUNTIME_DIR/vortex/phone` (Linux) or
+ `%LOCALAPPDATA%\Vortex\phone` (Windows), read-only, and every program on
+ the machine can read it.
+
+ The load-bearing decision on Linux is that **nothing blocks the FUSE session
+ thread**: each operation is handed to the async runtime and its reply object
+ (which fuser makes `Send` for exactly this) is answered when the phone
+ answers. Serving inline instead would cost one full round trip per operation
+ in series, and a file manager opening a folder issues dozens at once. On
+ Windows the same requirement is met by doing the opposite — see §4's table.
+
+ What is not there yet: writes (step 5 — the mount is `ro`, so the kernel
+ refuses them without a round trip), a content cache, coalescing, and
+ `statfs` numbers (there is no protocol op for free space, and inventing one
+ for a read-only mount would be a lie a file manager acts on).
+
+ Verified against a real kernel mount over a fake peer — `read_dir`, a
+ 100 KiB file read back byte-identical through the page cache, a `stat`, and
+ a write refused. That test needs `/dev/fuse`, so it is `#[ignore]`d and run
+ with `cargo test --lib fs_mount -- --ignored`.
+
+ Reachable from the UI: a folder button on the right of the phone card's
+ "Connected" row mounts on demand and hands the path to the platform's file
+ manager (`xdg-open`, or `explorer.exe`). Mounting is what can
+ fail — the phone may have gone since the card last said Connected — so the
+ button holds the error for a few seconds with the reason in its tooltip,
+ rather than opening a file manager onto nothing.
+
+ **Verified on the device** (2026-09-07, over Wi-Fi, all-files root). The
+ phone's shared storage appeared at `/run/user/1000/vortex/phone` as
+ `fuse.vortex (ro,nosuid,nodev,noexec,default_permissions)` with real names
+ and mtimes; a cold listing took ~300 ms and a subdirectory 12 ms.
+
+ * A 52 MB 4K video: `md5sum` matched the phone's in 7.4 s (7.1 MB/s), and
+ `ffprobe` read its codec, resolution and duration — a real seeking
+ consumer, not just a sequential one.
+ * A 481 MB APK: `cat | md5sum` matched in 66 s (7.3 MB/s) while the phone's
+ Java heap went 30.7 MB → 17.6 MB (a GC ran) and its native heap sat at
+ 12.1 MB. Nothing is buffered, at 7.5x the size of the cap this feature
+ started out working around.
+ * A 3.4 GB ROM zip listed with its true size, and the last 64 KiB read at
+ offset 3,396,354,250 matched the phone's md5 of the same range — past
+ 2^31, so the 64-bit offsets survive the whole stack.
+ * `touch` in the mount: "Read-only file system", refused by the kernel
+ without a round trip.
+
+ **The Windows half has never run.** There is no Windows machine in this
+ project's loop, so ProjFS is verified only as far as a cross-compile reaches.
+ A whole binary does cross-build from Linux, which is further than a check:
+
+ ```text
+ cd linux/ui-tauri && npm run build # the embedded frontend
+ cd src-tauri && cargo build --release \
+ --target x86_64-pc-windows-gnu \
+ --features custom-protocol --bin vortex-ui-tauri
+ ```
+
+ needing only mingw-w64 (`-gnu`, not `-msvc`: an MSVC cross-link wants
+ `lib.exe`). The result imports all eleven `Prj*` entry points from
+ `projectedfslib.dll`, which is the strongest check available here that the
+ FFI is wired correctly rather than merely type-correct. Ship
+ `WebView2Loader.dll` beside it — it is a dynamic import, and the app will
+ not start without it.
+
+ `cargo check --all-targets --target x86_64-pc-windows-gnu` is clean, which
+ type-checks every callback signature, struct layout and constant against the
+ real Win32 metadata — and nothing about behaviour. What that cannot catch is
+ the ProjFS *protocol*: enumeration restart and buffer-full handling, the
+ write-alignment rule, whether the notification veto covers every path to a
+ write. Those are written from the documented contract with the reasoning in
+ comments, and they are what a first run on Windows should be expected to
+ shake out. The shared layer under it (`fs_vfs.rs`) is tested on every
+ platform, which is deliberately where the path walk lives — it is the
+ hardest part of the Windows adapter and the part least able to be tested
+ there.
+
+ Two things that only a live run surfaced. Every path walk was asking us
+ `access` and every `close` a `flush`, and `getxattr`/`listxattr` on top —
+ fuser logs each as "[Not Implemented]", so an ordinary `ls` wrote warnings
+ into the app's log and paid a session round trip to answer "yes". Answering
+ them locally (and handing permission checks to the kernel with
+ `default_permissions`, which is what stops `access` being sent at all) took
+ that to zero and the 52 MB read from 8.6 s to 7.4 s. And the stale-mount
+ recovery was in the wrong order: a mount whose server was SIGKILLed (a
+ crash, or the installer restarting the app) stays in the table answering
+ `ENOTCONN`, which includes the `stat` inside `create_dir_all` — so
+ *creating* the mount point failed with EEXIST before the code that clears
+ the corpse ever ran.
+
+Steps 1–2 are worth doing regardless of whether the mount ever ships, which is
+the main argument for this ordering.
+
+## 8b. Browsing the laptop from the phone
+
+The protocol is symmetric, so this needed no new frames: the phone sends the
+same ops it answers. `FsClient` is the consumer half (pipelined, id-correlated,
+20 s timeout), `LaptopFilesScreen` browses and downloads to `Downloads/`, and
+the laptop's roots config decides what is visible.
+
+**Verified on the device** (2026-09-06): listed the laptop's home directory,
+descended two levels, and downloaded both a 1 KB file (one read) and a 299 KB
+file (multiple ranged reads) — both byte-identical by md5.
+
+**The opening request rides BLE; everything after it rides Wi-Fi.** The phone
+cannot dial the laptop — the laptop runs no listener — so it cannot open a LAN
+session itself. What it can do is answer on the session the LAPTOP opens to
+deliver its reply: that socket is bidirectional, and the laptop's dispatcher
+serves an `FS_REQ` arriving on it whichever side sent it. So the first request
+of a browse goes over BLE, the laptop's reply brings the session up, and the
+phone sends everything after it there — including every ranged read of a
+download. Measured: 18.4 MB in 8 s (~2.3 MB/s) against ~40 KiB/s on BLE.
+
+The phone binds its sender to the connection that has actually carried an FS
+frame, not the newest one, because the laptop also opens short-lived heartbeat
+sessions and a request sent down one of those would die with it.
+
+**A note on what remains BLE-only.** The laptop prefers Wi-Fi for the same traffic in the
+other direction, and it can because the PHONE listens on TCP and the laptop
+dials it. There is no listener the other way, so a phone-initiated LAN session
+has nothing to connect to. Listings are small and fine over BLE; pulling a large
+file this way runs at ~40 KiB/s. Closing that gap means giving the laptop a
+listener — worth doing, and the natural companion to step 3.
+
+## 9. Open questions
+
+- **ProjFS is imported at load time, and that is wrong for shipping.** The
+ eleven `Prj*` calls are ordinary static imports, so if `projectedfslib.dll`
+ is absent — possible on a machine where the optional feature has never been
+ enabled — Windows refuses to start the *whole app*, with a missing-DLL error
+ rather than the mount politely declining. A user who never wanted to browse
+ their phone's files would lose notifications, clipboard and calls with it.
+ The fix is to reach ProjFS through `LoadLibrary`/`GetProcAddress` instead,
+ which costs the compiler-checked signatures the `windows` crate currently
+ gives us — a bad trade while nothing has run, a necessary one before release.
+ (Delay-loading is not the answer on its own: a delay-load failure raises a
+ structured exception, so it needs a failure hook to become an error.)
+- ~~**Windows `FileSizeLimitInBytes`:** ship a registry tweak in the installer,
+ document it, or skip straight to ProjFS?~~ **Answered: straight to ProjFS**,
+ so the limit never applies. What replaces it as a Windows deployment question
+ is that ProjFS is an optional feature, off by default on client SKUs — the
+ mount error says how to enable it, and an installer step could do it instead.
+- **Handle lifetime** across phone process death — the daemon must transparently
+ reopen, or the file manager will see spurious I/O errors after a Doze kill.
+- **Multi-peer:** with several paired phones, is the mount per-phone (a mount
+ point each) or does it follow the active peer? Per-phone is more predictable
+ but multiplies mounts. *Answered by construction for now:* the protocol
+ client takes no peer — it sends to whichever session is up — so the single
+ mount follows the active peer. A per-phone mount is not expressible until the
+ client is addressed by peer, which is the real prerequisite here.
+- **Thumbnails:** let the desktop generate them by reading bytes (simple, heavy
+ on the link), or ask the phone for MediaStore thumbnails (fast, needs another
+ op)?
diff --git a/docs/design/multi-peer.md b/docs/design/multi-peer.md
new file mode 100644
index 0000000..055d9ef
--- /dev/null
+++ b/docs/design/multi-peer.md
@@ -0,0 +1,265 @@
+# Multi-peer pairing: one phone ↔ many laptops, one laptop ↔ many phones
+
+**Status:** design, not implemented. **Scope:** V1.x, no wire break.
+
+Goal: a phone may remember several laptops and a laptop several phones, with
+**at most one active link at a time**. Switching between remembered devices must
+not require forgetting one of them.
+
+The "not simultaneously" constraint is what keeps this small. It removes session
+multiplexing, concurrent transports, and conflict resolution on shared state.
+What remains is: be *discoverable* by all remembered peers, *pick* one, and
+*scope* per-peer state.
+
+---
+
+## 1. What already works
+
+Multi-peer was partly anticipated. Verified in the current tree:
+
+| Capability | Where | State |
+|---|---|---|
+| Laptop accepts *any* trusted peer's presence token | `linux/ui-tauri/src-tauri/src/ble.rs:100` (`expected_presence_tokens`) | iterates all peers ✅ |
+| Phone's IK responder identifies *which* peer is calling | `ReconnectOrchestrator.kt:132` — tries each peer's PRS as prologue | multi-peer ✅ |
+| Per-peer secrets (PRS, counters, nonces, bonded addr) | Secret Service attrs keyed by `peer_static_pub` | already per-peer ✅ |
+| Laptop can pair-scan while trust exists | `useHome.ts` `runScanLoop` condition includes `showPairPhoneModal` | ✅ |
+| Protocol is additively extensible | `shared/proto/vortex.proto` — *"New payload types must go through `VortexMessage.payload` oneof only"*, plus `capability_flags` | ✅ |
+
+So this is not a rewrite.
+
+## 2. The blocker
+
+Discovery identity is welded to exactly one peer:
+
+```
+token = HMAC-SHA256(peer.prs, "vortex/v1/presence" ‖ u64_be(bucket))[0..8]
+```
+
+There is one 8-byte slot, and the ADV_IND is already at the legacy 31-byte
+ceiling (3-byte Flags AD + 28-byte Service Data AD) — no room for a second
+token. The *same* construction names the mDNS record
+(`LanServer.kt:derivePrivateInstanceName`).
+
+Both call sites resolve the peer with `peerStore.list().firstOrNull()`. So "who
+can find me" is a per-PRS property fixed to peer[0], and forgetting is the only
+way to change it. Everything else below is downstream of this one fact.
+
+---
+
+## 3. Decisions
+
+### D1 — Keep per-peer tokens; time-multiplex one advertising set
+
+While seeking, the phone cycles candidate peers on a single advertising set,
+dwelling ~1–2 s per token. Worst-case discovery is `(N−1) × dwell` — a few
+seconds, on a deliberate, user-initiated handoff.
+
+**Rejected: one shared device-level presence key** (`PRS_pres` distributed at
+pairing, so a single token serves every peer). It is cheaper on air, but:
+
+- it is a wire-contract change, and
+- a *revoked* laptop retains the ability to recognise the phone until the key is
+ rotated and redistributed to every survivor.
+
+Per-peer tokens have no such revocation hole: forgetting a peer deletes its PRS,
+so the phone can no longer derive that token at all. A forgotten laptop becomes
+exactly as able to find the phone as a stranger's — not at all. **Per-peer tokens
+are both cheaper to ship and strictly better for revocation.**
+
+**Rejected: N concurrent advertising sets.** Viable on hardware (the test phone
+reports `max_adv_instances: 16`), but costs N× radio duty cycle permanently, and
+OEM background-advertising throttling — already documented in `Advertiser.kt` —
+makes it degrade unevenly across phones. Unnecessary once seeking is rare.
+
+### D2 — No wire break, and **no new advertising flag bit**
+
+`AdvFlags::is_well_formed()` (Rust `core/ble/mod.rs`, mirrored in
+`AdvPayload.kt:29`) requires reserved bits zero **and exactly one** of
+bit0 `PAIRABLE` / bit1 `TRUSTED_PRESENCE`. A third "SEEKING" mode bit would be
+rejected as malformed by **every deployed peer**.
+
+Seeking therefore advertises `flags = TRUSTED_PRESENCE` and varies only
+`AdvertiseSettings` mode and dwell. The state is local; the wire is unchanged.
+
+### D3 — "Switch" seeks *before* it releases
+
+Switch is **not** a release. The current link is held while the device scans (or
+advertises) for another *already-remembered* peer. Only once a replacement is
+identified — and chosen, if there is more than one — is the old link dropped.
+
+This is why there is no standby/suppression window: the device never lets go, so
+its own reconnect loop has nothing to race back into. The current peer is
+excluded from the candidate set by construction. Notably it also cannot get
+stuck connected to nothing.
+
+Switch is **not** Forget. Forget already exists and remains the escape hatch for
+a peer that is out of reach (see §7).
+
+### D4 — Separate *connected* from *active*
+
+The arbiter owns exactly one `activePeer`. Confirming a switch flips ownership
+**atomically**, demoting the old peer with an explicit "no longer active" reason.
+
+Transport teardown may then be lazy. Without this split, "connect to B and let
+backoff drop A" leaves two live links that both mirror notifications and sync
+clipboard — duplicated state, and a violation of the one-active-link rule. With
+it, overlapping *connections* are harmless because only one is *active*.
+
+Losers of an arbitration race must receive an explicit **busy** refusal so they
+back off rather than hammering the phone's single GATT link.
+
+### D5 — Phone advertising state machine
+
+| State | Advertising | Enter on |
+|---|---|---|
+| `Pairable` | LOW_LATENCY, `PAIRABLE` flag | peer list **empty**, or explicit "Add pair" |
+| `Active` | **none** | `activePeer` connected |
+| `Seeking` | `TRUSTED_PRESENCE`, backoff ladder, tokens multiplexed (D1) | Switch pressed, or link down |
+| `Dark` | **today's steady-state advertising** (see below) | seeking ladder exhausted |
+
+`Active` = silent is the main battery win: the phone is connected most of the
+time, and today it advertises 24/7 regardless.
+
+Ladder: LOW_LATENCY ≈ 30 s (the user is walking to the other machine) →
+BALANCED a few minutes → LOW_POWER → `Dark`.
+
+**`Dark` keeps advertising** rather than going silent. Two hard reasons:
+
+1. **Proximity lock depends on it.** `proximity.rs` treats "not away" as *active
+ session **or** token-validated advertisement*, with `AWAY_GRACE_MS = 25_000`
+ and a `CONFIRM_SCAN_MS = 2_000` last-chance scan whose sizing comment
+ explicitly assumes *"a present phone is in the reconnect-seeking LOW_LATENCY
+ tier (~100 ms adv) when this runs."* A silent-but-present phone would be
+ locked out spuriously. The ladder floor must stay detectable inside a 2 s
+ scan.
+2. **It preserves the walk-back-to-desk auto-reconnect** promised in the README
+ ("devices reconnect on their own"), which a silent `Dark` would regress.
+
+`Active` = silent is nonetheless safe for proximity, because a live session is
+itself the presence proof.
+
+### D6 — Exiting `Dark`
+
+Trigger: **screen on + Vortex app brought to foreground/focus.** Wanting to
+reconnect is a deliberate act; requiring the app in front is acceptable UX.
+
+**Rejected: exit `Dark` on joining a Wi-Fi network where a peer was last seen.**
+It is nearly free (mDNS, no BLE) and was tempting, but Wi-Fi coverage is much
+larger than BLE and can be spotty. A walk-away could drop BLE, drop Wi-Fi, then
+*re-acquire* Wi-Fi while still far from the laptop, reconnecting and letting the
+laptop unlock from well outside BLE range. That defeats proximity lock/unlock.
+LAN must not be used as a proximity signal.
+
+Since `Dark` still advertises (D5), the cost of dropping this is small.
+
+### D7 — Namespace per-peer state by public key, not name
+
+```
+~/.cache/vortex/peers//{sms,contacts,call_log}.json
+```
+
+Display name lives *inside* the folder as data. The peer name arrives from the
+peer's APPROVE payload and is already sanitised on both sides
+(`sanitize_peer_name`) precisely because it is untrusted — it must never become
+a path component. Names also collide ("Laptop"), change, and may be non-ASCII.
+
+Splits per peer: SMS, contacts, call log, notifications, media state.
+Stays global (deliberately one shared list): notes/todos, clipboard history.
+
+### D8 — UI
+
+**Laptop (Tauri).** The connected-peer card gains a header "Switch device"
+icon-button. Disabled when no peer is connected — with nothing connected the
+device is already seeking and will find any remembered peer on its own, so the
+button has no work to do. Prefer an inline "Switching… **Cancel**" affordance
+over a confirm modal: the action is cheap and reversible during the seek window.
+
+Icon must be a bundled inline SVG, never a CDN reference — the app is
+offline-first by design, and hotlinked icon sets carry attribution terms. It has
+to read at 16–20 px, so keep any phone glyph *outside* the arrow arc; nested
+detail mushes at that size.
+
+**Phone.** Mirror the Switch button on the connected-laptop card, plus an "Add
+pair" button (the laptop already has one) so pairable mode is reachable while
+trust exists — today `MainActivity.onResume` only auto-enters pairable when
+`peerStore.list().isEmpty()`.
+
+**Picker.** When more than one candidate is found, ask which to connect to, MRU
+order, with last-seen. Excludes the currently-active peer (D3). A single
+candidate connects automatically. This also handles two laptops seeking the same
+phone: the phone asks, rather than silently arbitrating.
+
+### D9 — Bound the seek
+
+Switch must have an auto-expiry and a visible Cancel. Seeking *on top of* a live
+connection is the most expensive state in the system, so an unattended Switch
+(press it, walk to a room with no laptop) must not scan indefinitely — it
+returns to `Active`.
+
+---
+
+## 4. Protocol additions
+
+Additive only, gated on the existing `capability_flags`:
+
+- `PeerHandoff` in the `VortexMessage.payload` oneof — carries "release me / you
+ are no longer active", with a reason code (`switched`, `busy`, `revoked`).
+
+Old peers ignore an unknown oneof field; the capability bit prevents sending it
+to a peer that would not act on it. No change to `AdvPayload`, the advertising
+flags, the GATT UUIDs, or the token derivation.
+
+## 5. Sites to change
+
+**Android — `firstPeer` → `activePeer`:**
+
+| Site | Role |
+|---|---|
+| `MainActivityPairing.kt:234` | advertising-mode selection — *the blocker* |
+| `LanServer.kt:1039` | mDNS instance name |
+| `VortexStack.kt:678, 721, 740, 752, 899` | service-stack peer binding |
+| `MainActivityEarbuds.kt:77` | earbuds switch target |
+| `HomeScreen.kt:107` | UI primary card → device list |
+
+**Linux:** no first-peer assumptions in trust/session logic. Work is the cache
+namespacing (D7), the arbiter (D4), and the Switch UI (D8).
+
+## 6. Sequencing
+
+1. **Forget drops the BT bond on both sides** + **"Add pair" on the phone**.
+ No protocol work. Unblocks the immediate pain.
+2. **Per-peer cache namespacing** (D7). Prerequisite for laptop ↔ N phones.
+3. **Arbiter + Switch flow** (D3, D4, D8, D9) and the phone state machine (D5,
+ D6).
+
+Steps 1–2 are independent and separately shippable.
+
+## 7. Prerequisite: Forget is currently broken
+
+`BondCleaner.removeBond` is reachable **only** from the DEBUG dev-hook intent
+(`MainActivity.kt:269`); `onForgetPeerClicked` never calls it, and
+`cmd_pairing.rs` has no `remove_device`/unpair either. So Vortex's Forget leaves
+the BT bond in place on *both* sides.
+
+A one-sided bond — laptop's bond dropped, phone's retained — makes the link tear
+down during encryption, so `ServicesResolved` never arrives and pairing fails
+with `timeout: service discovery`. Observed live 2026-08-25; it is the cause of
+"retry pairing several times until it works".
+
+Because Switch is disabled when the peer is absent (D8), **Forget is the only
+escape from a phone bound to an unreachable laptop.** That escape hatch has to
+work before this design can rely on it.
+
+## 8. Open risks
+
+- **Multiplex dwell vs. OEM throttling.** Restarting an advertising set every
+ 1–2 s may land in a slower throttle tier on aggressive ROMs, and each restart
+ re-randomises the RPA. The laptop matches on token, not address, so this is
+ correctness-safe, but it inflates BlueZ's device cache — a known source of
+ stale-RPA connect attempts. Needs measurement on a throttling ROM.
+- **`Dark` re-entry latency.** Requiring app-foreground (D6) means a phone that
+ ladder-expired needs a deliberate user action to become *fast* again. Mitigated
+ by `Dark` still advertising, so reconnect works, just slower.
+- **Arbitration during simultaneous switch.** Both sides pressing Switch at once
+ is untested territory; the atomic `activePeer` flip (D4) should make it safe
+ but needs an explicit test.
diff --git a/linux/Cargo.lock b/linux/Cargo.lock
index a3a3cf3..e0b4616 100644
--- a/linux/Cargo.lock
+++ b/linux/Cargo.lock
@@ -72,7 +72,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -83,7 +83,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -193,9 +193,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
-version = "0.2.1"
+version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chacha20"
@@ -299,7 +299,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -320,7 +320,7 @@ dependencies = [
"darling",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
"synstructure",
]
@@ -345,7 +345,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -356,14 +356,14 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
name = "dbus"
-version = "0.9.11"
+version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73"
+checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e"
dependencies = [
"futures-channel",
"futures-util",
@@ -405,13 +405,13 @@ dependencies = [
[[package]]
name = "displaydoc"
-version = "0.2.5"
+version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 3.0.3",
]
[[package]]
@@ -438,7 +438,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -588,7 +588,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -958,7 +958,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -1031,22 +1031,22 @@ checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "pin-project"
-version = "1.1.12"
+version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9"
+checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
-version = "1.1.12"
+version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389"
+checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -1057,9 +1057,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
-version = "0.3.33"
+version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "poly1305"
@@ -1100,7 +1100,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -1289,7 +1289,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -1313,7 +1313,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -1435,7 +1435,7 @@ dependencies = [
"proc-macro2",
"quote",
"rustversion",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -1455,6 +1455,17 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "syn"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
[[package]]
name = "synstructure"
version = "0.13.2"
@@ -1463,7 +1474,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -1496,7 +1507,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -1533,14 +1544,14 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
name = "tokio-stream"
-version = "0.1.18"
+version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
+checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
dependencies = [
"futures-core",
"pin-project-lite",
@@ -1596,7 +1607,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -1731,6 +1742,7 @@ dependencies = [
"tracing",
"tracing-subscriber",
"uuid",
+ "windows",
"x25519-dalek",
"zbus",
]
@@ -1791,7 +1803,7 @@ dependencies = [
"bumpalo",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
"wasm-bindgen-shared",
]
@@ -1838,12 +1850,107 @@ dependencies = [
"semver",
]
+[[package]]
+name = "windows"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
+dependencies = [
+ "windows-collections",
+ "windows-core",
+ "windows-future",
+ "windows-numerics",
+]
+
+[[package]]
+name = "windows-collections"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
+dependencies = [
+ "windows-core",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-future"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
+dependencies = [
+ "windows-core",
+ "windows-link",
+ "windows-threading",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+[[package]]
+name = "windows-numerics"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
+dependencies = [
+ "windows-core",
+ "windows-link",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -1887,6 +1994,15 @@ dependencies = [
"windows_x86_64_msvc",
]
+[[package]]
+name = "windows-threading"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
+dependencies = [
+ "windows-link",
+]
+
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@@ -1980,7 +2096,7 @@ dependencies = [
"heck",
"indexmap",
"prettyplease",
- "syn",
+ "syn 2.0.117",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
@@ -1996,7 +2112,7 @@ dependencies = [
"prettyplease",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
"wit-bindgen-core",
"wit-bindgen-rust",
]
@@ -2089,7 +2205,7 @@ dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
"zbus_names",
"zvariant",
"zvariant_utils",
@@ -2123,7 +2239,7 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -2143,7 +2259,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
]
[[package]]
@@ -2175,7 +2291,7 @@ dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.117",
"zvariant_utils",
]
@@ -2188,6 +2304,6 @@ dependencies = [
"proc-macro2",
"quote",
"serde",
- "syn",
+ "syn 2.0.117",
"winnow",
]
diff --git a/linux/daemon/Cargo.toml b/linux/daemon/Cargo.toml
index 5a675f2..3292347 100644
--- a/linux/daemon/Cargo.toml
+++ b/linux/daemon/Cargo.toml
@@ -55,15 +55,73 @@ serde_json = "1"
x25519-dalek = { version = "2", features = ["static_secrets"] }
uuid = "1"
thiserror = "1"
-bluer = { version = "0.17", features = ["bluetoothd"] }
-tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread", "time", "signal"] }
+# The feature list must name everything we actually use. It used to stop at
+# "signal" and still built, because bluer/secret-service/zbus pulled the rest in
+# and Cargo unified the features — so moving those behind a target gate (below)
+# turned ~100 "module `sync` is private" errors loose on the Windows target.
+# Nothing here is platform-bound; the omission just wasn't visible with one OS.
+tokio = { version = "1", features = [
+ "macros",
+ "rt",
+ "rt-multi-thread",
+ "time",
+ "signal",
+ "sync", # mpsc/oneshot/Mutex/Notify/OnceCell, ~14 modules
+ "net", # the LAN TCP transport
+ "io-util", # AsyncReadExt/AsyncWriteExt over that transport
+ "process", # spawning helpers (pactl, loginctl, xdg-user-dir)
+] }
futures = "0.3"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
humantime = "2"
rand = "0.8"
-secret-service = { version = "5", features = ["rt-tokio-crypto-rust"] }
mdns-sd = "0.13"
+
+# ── platform-bound dependencies ───────────────────────────────────────────
+# Everything below is one OS's API surface and must stay behind
+# `core::platform`. Target-gating them is what lets `cargo check --target
+# x86_64-pc-windows-msvc` resolve at all: BlueZ, Secret Service and D-Bus have
+# no Windows build, and pulling them unconditionally fails during resolution
+# rather than in code you can cfg away.
+[target.'cfg(target_os = "windows")'.dependencies]
+# WinRT + Win32, feature-gated per namespace so we pull metadata for what we
+# actually call. BLE central lives in Devices_Bluetooth*; Devices_Enumeration is
+# how you find already-paired devices; Storage_Streams is the buffer type every
+# GATT read/write goes through; Win32_UI_Shell is SHGetKnownFolderPath and
+# Win32_System_Com is the CoTaskMemFree that pairs with it.
+windows = { version = "0.62", features = [
+ "Devices_Bluetooth",
+ "Devices_Bluetooth_Advertisement",
+ "Devices_Bluetooth_GenericAttributeProfile",
+ "Devices_Enumeration",
+ "Foundation",
+ "Foundation_Collections",
+ "Storage_Streams",
+ "Data_Xml_Dom",
+ "UI_Notifications",
+ "Win32_Foundation",
+ "Win32_Security_Credentials",
+ "Win32_System_Com",
+ # The AUMID shortcut that makes toasts visible at all: a PROPVARIANT in the
+ # shortcut's property store (StructuredStorage) keyed by a PROPERTYKEY
+ # (PropertiesSystem). See `notify::register_aumid_shortcut`.
+ "Win32_System_Com_StructuredStorage",
+ # PROPVARIANT is gated on the Variant namespace even though we only need its
+ # VT_LPWSTR tag.
+ "Win32_System_Variant",
+ "Win32_UI_Shell_PropertiesSystem",
+ "Win32_System_Registry",
+ "Win32_System_RemoteDesktop",
+ "Win32_System_Shutdown",
+ "Win32_System_WinRT",
+ "Win32_UI_Shell",
+ "Win32_UI_WindowsAndMessaging",
+] }
+
+[target.'cfg(target_os = "linux")'.dependencies]
+bluer = { version = "0.17", features = ["bluetoothd"] }
+secret-service = { version = "5", features = ["rt-tokio-crypto-rust"] }
# Re-use the zbus that secret-service already pulls in for direct
# MPRIS calls (call-handoff media pause/resume).
zbus = { version = "5", default-features = false, features = ["tokio"] }
diff --git a/linux/daemon/examples/secret_store_stress.rs b/linux/daemon/examples/secret_store_stress.rs
index 98ee405..1dbf1dd 100644
--- a/linux/daemon/examples/secret_store_stress.rs
+++ b/linux/daemon/examples/secret_store_stress.rs
@@ -16,7 +16,8 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
-use vortex_l3_daemon::core::storage::peers::{PeerStore, SecretServicePeerStore};
+use vortex_l3_daemon::core::storage::peers::PeerStore;
+use vortex_l3_daemon::core::storage::peers_secret_service::SecretServicePeerStore;
const FAKE_PEER: [u8; 32] = [0xEE; 32];
const BURST_TASKS: u64 = 32;
diff --git a/linux/daemon/src/core/appstate.rs b/linux/daemon/src/core/appstate.rs
index 3165f1f..a73e84f 100644
--- a/linux/daemon/src/core/appstate.rs
+++ b/linux/daemon/src/core/appstate.rs
@@ -435,10 +435,11 @@ impl AppState {
pub fn now_laptop() -> Self {
let battery = crate::core::status::read_local_battery().0;
let charging = crate::core::status::read_local_charging();
- let name = std::fs::read_to_string("/proc/sys/kernel/hostname")
- .ok()
- .map(|s| s.trim().to_string())
- .filter(|s| !s.is_empty());
+ // Via the seam: this used to read `/proc` directly, so off Linux every
+ // heartbeat reported `None` and the phone displayed the laptop as
+ // "null" — overwriting the name it had just learned from the pairing
+ // APPROVE frame, which comes from the same function.
+ let name = crate::core::platform::host_name();
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
diff --git a/linux/daemon/src/core/ble/audio_signal.rs b/linux/daemon/src/core/ble/audio_signal.rs
index 80ff0e2..822ed8f 100644
--- a/linux/daemon/src/core/ble/audio_signal.rs
+++ b/linux/daemon/src/core/ble/audio_signal.rs
@@ -36,20 +36,16 @@ pub(crate) static RESYNC_EVENTS: AtomicU64 = AtomicU64::new(0);
pub(crate) static RESYNC_FRAMES_SKIPPED: AtomicU64 = AtomicU64::new(0);
pub(crate) static REHANDSHAKE_EVENTS: AtomicU64 = AtomicU64::new(0);
-use futures::{pin_mut, StreamExt};
use snow::TransportState;
use tokio::sync::Mutex;
use tracing::{debug, info, warn};
-use bluer::gatt::remote::{Characteristic, CharacteristicWriteRequest};
-use bluer::gatt::WriteOp;
-use super::client::VortexClient;
+use super::AUDIO_SIGNAL_UUID;
+use crate::core::platform::{AudioHandoff, GattLink};
use super::frame::{ty, Frame};
use crate::core::appstate::AppState;
use crate::core::audio_op::{AudioOp, AudioOpFrame};
-use crate::core::audio_orchestrator::SwitchOrchestrator;
-use crate::core::media_runtime::{pause_playing_for_call, MediaStateStore};
/// Run the AUDIO_SIGNAL listener loop until the BLE notification stream
/// closes (peer drops, adapter goes down, etc).
@@ -59,11 +55,13 @@ use crate::core::media_runtime::{pause_playing_for_call, MediaStateStore};
/// the session is unsafe to continue (replayed nonces are NOT errors —
/// the orchestrator silently drops them, same as the LAN path).
pub async fn run_listener(
- client: &VortexClient,
+ link: &dyn GattLink,
transport: Arc>,
peer_pub: [u8; 32],
- orchestrator: Arc,
- media_store: MediaStateStore,
+ // The local audio stack, for the one frame type that needs it. `None` on a
+ // platform with no audio backend: AUDIO_OP frames are then dropped and the
+ // other eighteen types carry on.
+ audio: Option>,
// Additive state-push channel: a STATE frame (battery/charging) is
// decoded to an `AppState` and forwarded here as (peer_pub, state).
// The UI layer turns it into the same peer-state update a LAN
@@ -127,21 +125,29 @@ pub async fn run_listener(
// from phone" pill. `None` drops them. Never touches the audio handoff.
handoff_tx: Option>,
// Generic additive-frame channel: any allowed frame WITHOUT a dedicated
- // handler above is forwarded raw as (frame_ty, decrypted_payload) here, so
- // a new feature (e.g. notes) routes it entirely in its OWN module — no
- // per-feature code in this transport file. `None` drops them.
- raw_frame_tx: Option)>>,
+ // handler above is forwarded raw as (peer_pub, frame_ty,
+ // decrypted_payload) here, so a new feature (e.g. notes, peer handoff)
+ // routes it entirely in its OWN module — no per-feature code in this
+ // transport file. `None` drops them.
+ //
+ // The peer identity is part of the tuple because a frame's meaning can
+ // depend on WHO sent it: `PEER_HANDOFF` says "you are no longer my active
+ // peer", which is unactionable without knowing whose statement it is.
+ // Inferring it from "whoever is active right now" would be a guess.
+ raw_frame_tx: Option<
+ tokio::sync::mpsc::UnboundedSender,
+ >,
) -> Result<(), String> {
- let char = client
- .audio_signal
- .as_ref()
- .ok_or_else(|| "peer has no AUDIO_SIGNAL characteristic".to_string())?;
- let notifies = char
- .notify()
- .await
- .map_err(|e| format!("subscribe AUDIO_SIGNAL: {e}"))?;
- pin_mut!(notifies);
- info!(addr = %client.address, "BLE audio-signal listener up");
+ if !link.has(AUDIO_SIGNAL_UUID.as_u128()) {
+ return Err("peer has no AUDIO_SIGNAL characteristic".to_string());
+ }
+ // The seam delivers frames on a channel rather than as a Stream. Same
+ // ordering guarantee, which is what matters here: this cipher stream is
+ // nonce-sequenced, so a reordered frame would look exactly like a dropped
+ // one and burn a resync.
+ let (tx, mut notifies) = tokio::sync::mpsc::unbounded_channel::>();
+ link.subscribe(AUDIO_SIGNAL_UUID.as_u128(), tx).await?;
+ info!(addr = %link.peer(), "BLE audio-signal listener up");
// Reconcile on (re)connect: ask the phone to re-send any active notification
// we don't already have. Covers notifications posted while we were
// disconnected (their notify had no subscriber) — the consumer carries our
@@ -185,8 +191,8 @@ pub async fn run_listener(
loop {
let raw: Vec = match reassembled.pop_front() {
Some(inner) => inner,
- None => match notifies.next().await {
- Some(r) => r.to_vec(),
+ None => match notifies.recv().await {
+ Some(r) => r,
None => break,
},
};
@@ -227,6 +233,11 @@ pub async fn run_listener(
&& frame.ty != ty::WIFI_DIRECT_OFFER
&& frame.ty != ty::HANDOFF
&& frame.ty != ty::NOTES_SYNC
+ && frame.ty != ty::PEER_HANDOFF
+ && frame.ty != ty::FS_REQ
+ && frame.ty != ty::FS_META
+ && frame.ty != ty::FS_DATA
+ && frame.ty != ty::FS_ERR
{
warn!(
"audio-signal unexpected frame ty=0x{:02x}; ignoring",
@@ -586,7 +597,12 @@ pub async fn run_listener(
// forward it raw to its own module. No feature logic in this file.
if frame.ty != ty::AUDIO_OP {
if let Some(tx) = raw_frame_tx.as_ref() {
- let _ = tx.send((frame.ty, plain[..n].to_vec()));
+ let _ = tx.send(crate::core::ble::frame::RawFrame {
+ peer_pub,
+ ty: frame.ty,
+ sub: frame.sub,
+ payload: plain[..n].to_vec(),
+ });
}
continue;
}
@@ -608,25 +624,22 @@ pub async fn run_listener(
// leaving `pause_playing_for_call` with nothing to track on a
// later resume. Doing it here, in parallel with the dispatch,
// captures the playing set while it's still actually playing.
+ let Some(audio) = audio.as_ref() else {
+ debug!("no audio backend on this platform; dropping AUDIO_OP");
+ continue;
+ };
if matches!(af.op, AudioOp::Request) {
- let store = media_store.clone();
- tokio::spawn(async move {
- let paused = pause_playing_for_call(&store).await;
- if !paused.is_empty() {
- info!(?paused, "BLE fast-path: paused MPRIS for call");
- }
- });
+ let audio = Arc::clone(audio);
+ tokio::spawn(async move { audio.pause_for_call().await });
}
// Dispatch on a fresh task so a slow responder can't stall the
// notification stream. Same shape as audio_lan_session.rs uses.
- let orch = orchestrator.clone();
+ let audio = Arc::clone(audio);
let peer_copy = peer_pub;
- tokio::spawn(async move {
- let _ = orch.on_incoming(peer_copy, af).await;
- });
+ tokio::spawn(async move { audio.on_incoming(peer_copy, af).await });
}
- info!(addr = %client.address, "BLE audio-signal listener: stream closed");
+ info!(addr = %link.peer(), "BLE audio-signal listener: stream closed");
Ok(())
}
@@ -644,14 +657,13 @@ pub async fn run_listener(
/// dispatches into `SwitchOrchestrator.onIncoming` — same dispatch
/// path as the LAN session.
pub async fn write_audio_op(
- client: &VortexClient,
+ link: &dyn GattLink,
transport: Arc>,
frame: AudioOpFrame,
) -> Result<(), String> {
- let char = client
- .audio_signal
- .as_ref()
- .ok_or_else(|| "peer has no AUDIO_SIGNAL characteristic".to_string())?;
+ if !link.has(AUDIO_SIGNAL_UUID.as_u128()) {
+ return Err("peer has no AUDIO_SIGNAL characteristic".to_string());
+ }
let json = frame
.to_json()
.map_err(|e| format!("AudioOpFrame to_json: {e}"))?;
@@ -659,7 +671,7 @@ pub async fn write_audio_op(
// ciphertext buffer accordingly and truncate to the bytes
// actually written.
let mut ct = vec![0u8; json.len() + 16];
- // Hold the lock across char.write — see write_state for why (nonce/wire
+ // Hold the lock across the write — see write_state for why (nonce/wire
// lockstep; otherwise concurrent writers desync the phone's recv cipher).
let mut t = transport.lock().await;
let n = t
@@ -667,7 +679,13 @@ pub async fn write_audio_op(
.map_err(|e| format!("audio-signal write_message: {e}"))?;
ct.truncate(n);
let wire = Frame::new(ty::AUDIO_OP, 0, ct).encode();
- char.write(&wire)
+ // The ONE unacknowledged writer: `with_response = false`, i.e. an ATT Write
+ // Command. Audio-op opcodes are tiny and latency-critical (they carry the
+ // ~200 ms call handoff), so they stay under the Command size cap and skip
+ // the ACK that [`write_framed`] needs for everything larger. Previously
+ // this was bluer's bare `char.write()`, whose default IS a Command — the
+ // choice was implicit in the method name, so it is spelled out here.
+ link.write(AUDIO_SIGNAL_UUID.as_u128(), &wire, false)
.await
.map_err(|e| format!("BLE write to AUDIO_SIGNAL: {e}"))?;
drop(t);
@@ -689,9 +707,8 @@ pub async fn write_audio_op(
/// silently-dropped Command desynced it → "AEAD open failed" → session churn).
/// AUDIO_SIGNAL advertises PROPERTY_WRITE, so a Request is valid. Used for every
/// laptop→phone frame except the tiny latency-critical audio-op opcodes.
-async fn write_framed(char: &Characteristic, wire: &[u8]) -> bluer::Result<()> {
- let req = CharacteristicWriteRequest { op_type: WriteOp::Request, ..Default::default() };
- char.write_ext(wire, &req).await
+async fn write_framed(link: &dyn GattLink, wire: &[u8]) -> Result<(), String> {
+ link.write(AUDIO_SIGNAL_UUID.as_u128(), wire, true).await
}
/// Push an `AppState` (battery/charging) to the peer over the AUDIO_SIGNAL
@@ -701,14 +718,13 @@ async fn write_framed(char: &Characteristic, wire: &[u8]) -> bluer::Result<()> {
/// laptop's power-watcher to push instantly over BLE instead of waiting
/// for the LAN heartbeat.
pub async fn write_state(
- client: &VortexClient,
+ link: &dyn GattLink,
transport: Arc>,
state: &AppState,
) -> Result<(), String> {
- let char = client
- .audio_signal
- .as_ref()
- .ok_or_else(|| "peer has no AUDIO_SIGNAL characteristic".to_string())?;
+ if !link.has(AUDIO_SIGNAL_UUID.as_u128()) {
+ return Err("peer has no AUDIO_SIGNAL characteristic".to_string());
+ }
let json = serde_json::to_vec(state).map_err(|e| format!("AppState to_json: {e}"))?;
let mut ct = vec![0u8; json.len() + 16];
// Hold the transport lock ACROSS the BLE write: the AEAD nonce bump and the
@@ -723,7 +739,7 @@ pub async fn write_state(
.map_err(|e| format!("state write_message: {e}"))?;
ct.truncate(n);
let wire = Frame::new(ty::STATE, 0, ct).encode();
- write_framed(char, &wire)
+ write_framed(link, &wire)
.await
.map_err(|e| format!("BLE write STATE to AUDIO_SIGNAL: {e}"))?;
drop(t);
@@ -737,14 +753,13 @@ pub async fn write_state(
/// notification display, never to the audio orchestrator. Content is not
/// logged.
pub async fn write_notification(
- client: &VortexClient,
+ link: &dyn GattLink,
transport: Arc>,
notif: &crate::core::notif_mirror::NotificationMirror,
) -> Result<(), String> {
- let char = client
- .audio_signal
- .as_ref()
- .ok_or_else(|| "peer has no AUDIO_SIGNAL characteristic".to_string())?;
+ if !link.has(AUDIO_SIGNAL_UUID.as_u128()) {
+ return Err("peer has no AUDIO_SIGNAL characteristic".to_string());
+ }
let json = serde_json::to_vec(notif).map_err(|e| format!("notif to_json: {e}"))?;
let mut ct = vec![0u8; json.len() + 16];
// Hold the lock across char.write — see write_state (nonce/wire lockstep).
@@ -754,7 +769,7 @@ pub async fn write_notification(
.map_err(|e| format!("notif write_message: {e}"))?;
ct.truncate(n);
let wire = Frame::new(ty::NOTIFICATION, 0, ct).encode();
- write_framed(char, &wire)
+ write_framed(link, &wire)
.await
.map_err(|e| format!("BLE write NOTIFICATION to AUDIO_SIGNAL: {e}"))?;
debug!(app = %notif.app, "→ BLE notification push (laptop→phone)");
@@ -767,23 +782,23 @@ pub async fn write_notification(
/// feature knowledge — a feature module (e.g. notes) supplies its own frame
/// type + payload, keeping all of its logic in its own file.
pub async fn write_sealed(
- client: &VortexClient,
+ link: &dyn GattLink,
transport: Arc>,
ty: u8,
+ sub: u8,
payload: &[u8],
) -> Result<(), String> {
- let char = client
- .audio_signal
- .as_ref()
- .ok_or_else(|| "peer has no AUDIO_SIGNAL characteristic".to_string())?;
+ if !link.has(AUDIO_SIGNAL_UUID.as_u128()) {
+ return Err("peer has no AUDIO_SIGNAL characteristic".to_string());
+ }
let mut ct = vec![0u8; payload.len() + 16];
let mut t = transport.lock().await;
let n = t
.write_message(payload, &mut ct)
.map_err(|e| format!("sealed write_message: {e}"))?;
ct.truncate(n);
- let wire = Frame::new(ty, 0, ct).encode();
- write_framed(char, &wire)
+ let wire = Frame::new(ty, sub, ct).encode();
+ write_framed(link, &wire)
.await
.map_err(|e| format!("BLE write 0x{ty:02x} to AUDIO_SIGNAL: {e}"))?;
Ok(())
@@ -794,14 +809,13 @@ pub async fn write_sealed(
/// writers; the phone routes CLIPBOARD frames to its system clipboard.
/// Content is not logged (only length).
pub async fn write_clipboard(
- client: &VortexClient,
+ link: &dyn GattLink,
transport: Arc>,
clip: &crate::core::clipboard_mirror::ClipboardMirror,
) -> Result<(), String> {
- let char = client
- .audio_signal
- .as_ref()
- .ok_or_else(|| "peer has no AUDIO_SIGNAL characteristic".to_string())?;
+ if !link.has(AUDIO_SIGNAL_UUID.as_u128()) {
+ return Err("peer has no AUDIO_SIGNAL characteristic".to_string());
+ }
// Long text would overflow a single BLE frame → chunk it over CLIPBOARD_TEXT
// (same `[total][idx][data]` wire + 12ms pacing as the image sender). Short
// text keeps the fast single-frame CLIPBOARD path.
@@ -826,7 +840,7 @@ pub async fn write_clipboard(
.map_err(|e| format!("clipboard-text write_message: {e}"))?;
ct.truncate(n);
let wire = Frame::new(ty::CLIPBOARD_TEXT, 0, ct).encode();
- write_framed(char, &wire)
+ write_framed(link, &wire)
.await
.map_err(|e| format!("BLE write CLIPBOARD_TEXT to AUDIO_SIGNAL: {e}"))?;
}
@@ -849,7 +863,7 @@ pub async fn write_clipboard(
.map_err(|e| format!("clipboard write_message: {e}"))?;
ct.truncate(n);
let wire = Frame::new(ty::CLIPBOARD, 0, ct).encode();
- write_framed(char, &wire)
+ write_framed(link, &wire)
.await
.map_err(|e| format!("BLE write CLIPBOARD to AUDIO_SIGNAL: {e}"))?;
debug!(chars = clip.text.chars().count(), "→ BLE clipboard push (laptop→phone)");
@@ -860,14 +874,13 @@ pub async fn write_clipboard(
/// CLIPBOARD_IMAGE chunk frames. Each chunk is AEAD-sealed and paced so the
/// BLE notify queue doesn't overflow (same discipline as the icon sender).
pub async fn write_clipboard_image(
- client: &VortexClient,
+ link: &dyn GattLink,
transport: Arc>,
png: &[u8],
) -> Result<(), String> {
- let char = client
- .audio_signal
- .as_ref()
- .ok_or_else(|| "peer has no AUDIO_SIGNAL characteristic".to_string())?;
+ if !link.has(AUDIO_SIGNAL_UUID.as_u128()) {
+ return Err("peer has no AUDIO_SIGNAL characteristic".to_string());
+ }
let chunks = crate::core::clipboard_mirror::build_image_chunks(png);
let total = chunks.len();
for payload in chunks {
@@ -886,7 +899,7 @@ pub async fn write_clipboard_image(
.map_err(|e| format!("clipboard-image write_message: {e}"))?;
ct.truncate(n);
let wire = Frame::new(ty::CLIPBOARD_IMAGE, 0, ct).encode();
- write_framed(char, &wire)
+ write_framed(link, &wire)
.await
.map_err(|e| format!("BLE write CLIPBOARD_IMAGE to AUDIO_SIGNAL: {e}"))?;
}
@@ -901,14 +914,13 @@ pub async fn write_clipboard_image(
/// CALL_CONTROL frame (0x38). Same lock-across-write nonce discipline as the
/// other writers; routed separately from the audio handoff.
pub async fn write_call_control(
- client: &VortexClient,
+ link: &dyn GattLink,
transport: Arc>,
ctrl: &crate::core::call_event::CallControl,
) -> Result<(), String> {
- let char = client
- .audio_signal
- .as_ref()
- .ok_or_else(|| "peer has no AUDIO_SIGNAL characteristic".to_string())?;
+ if !link.has(AUDIO_SIGNAL_UUID.as_u128()) {
+ return Err("peer has no AUDIO_SIGNAL characteristic".to_string());
+ }
let json = ctrl.to_json();
let mut ct = vec![0u8; json.len() + 16];
let mut t = transport.lock().await;
@@ -917,9 +929,191 @@ pub async fn write_call_control(
.map_err(|e| format!("call-control write_message: {e}"))?;
ct.truncate(n);
let wire = Frame::new(ty::CALL_CONTROL, 0, ct).encode();
- write_framed(char, &wire)
+ write_framed(link, &wire)
.await
.map_err(|e| format!("BLE write CALL_CONTROL to AUDIO_SIGNAL: {e}"))?;
debug!(action = %ctrl.action, "→ BLE call-control push (laptop→phone)");
Ok(())
}
+
+#[cfg(test)]
+mod link_tests {
+ use super::*;
+ use crate::core::crypto::noise::{NOISE_IK, PROLOGUE_IK};
+ use crate::core::platform::FakeGattLink;
+ use snow::Builder;
+
+ /// A matched pair of transport states, as an IK handshake leaves them.
+ ///
+ /// The receive path is nonce-sequenced, so testing it needs a real cipher
+ /// pair rather than a stub: the whole point of the resync logic is what
+ /// happens to a REAL nonce sequence when a frame goes missing.
+ fn transport_pair() -> (TransportState, TransportState) {
+ let init_priv = [0x11u8; 32];
+ let resp_priv = [0x22u8; 32];
+ let resp_pub = {
+ let d = x25519_dalek::StaticSecret::from(resp_priv);
+ *x25519_dalek::PublicKey::from(&d).as_bytes()
+ };
+ let params: snow::params::NoiseParams = NOISE_IK.parse().unwrap();
+ let mut init = Builder::new(params.clone())
+ .local_private_key(&init_priv)
+ .unwrap()
+ .remote_public_key(&resp_pub)
+ .unwrap()
+ .prologue(PROLOGUE_IK)
+ .unwrap()
+ .build_initiator()
+ .unwrap();
+ let mut resp = Builder::new(params)
+ .local_private_key(&resp_priv)
+ .unwrap()
+ .prologue(PROLOGUE_IK)
+ .unwrap()
+ .build_responder()
+ .unwrap();
+ let mut b1 = vec![0u8; 1024];
+ let mut b2 = vec![0u8; 1024];
+ let n = init.write_message(&[], &mut b1).unwrap();
+ resp.read_message(&b1[..n], &mut b2).unwrap();
+ let n = resp.write_message(&[], &mut b2).unwrap();
+ init.read_message(&b2[..n], &mut b1).unwrap();
+ (
+ init.into_transport_mode().unwrap(),
+ resp.into_transport_mode().unwrap(),
+ )
+ }
+
+ /// Seal `payload` as the phone would, with the phone's send cipher.
+ fn phone_frame(phone: &mut TransportState, ty_byte: u8, payload: &[u8]) -> Vec {
+ let mut ct = vec![0u8; payload.len() + 16];
+ let n = phone.write_message(payload, &mut ct).unwrap();
+ ct.truncate(n);
+ Frame::new(ty_byte, 0, ct).encode()
+ }
+
+ /// Spawn the listener with only a raw-frame channel wired, and return it.
+ fn spawn_listener(
+ link: Arc,
+ laptop: TransportState,
+ ) -> (
+ tokio::sync::mpsc::UnboundedReceiver,
+ tokio::task::JoinHandle>,
+ ) {
+ // Per-peer: the listener stamps every raw frame with the peer it came
+ // from, so a multi-peer consumer knows whose statement it is. The
+ // listener below is given the all-zero key, so that is what arrives.
+ let (raw_tx, raw_rx) =
+ tokio::sync::mpsc::unbounded_channel::();
+ let handle = tokio::spawn(async move {
+ run_listener(
+ &*link,
+ Arc::new(Mutex::new(laptop)),
+ [0u8; 32],
+ None, // no audio backend: exactly the Windows shape
+ // 13 feature channels we don't need here, then the raw one.
+ None, None, None, None, None, None, None, None, None, None, None,
+ None, None,
+ Some(raw_tx),
+ )
+ .await
+ });
+ (raw_rx, handle)
+ }
+
+ /// The dropped-notify recovery, driven deliberately rather than observed in
+ /// the wild.
+ ///
+ /// A BLE notify lost in flight desyncs the receive nonce, and every frame
+ /// after it fails to decrypt — permanently, unless the reader walks forward
+ /// to find the nonce that authenticates. This is the code that saved a
+ /// dropped file offer in production; now it has a test that skips a frame
+ /// on purpose and asserts the NEXT one still arrives.
+ #[tokio::test]
+ async fn a_dropped_frame_resyncs_instead_of_wedging_the_stream() {
+ let link = Arc::new(FakeGattLink::new(vec![AUDIO_SIGNAL_UUID.as_u128()]));
+ let (laptop, mut phone) = transport_pair();
+ let before = RESYNC_EVENTS.load(Ordering::Relaxed);
+ let (mut raw_rx, handle) = spawn_listener(Arc::clone(&link), laptop);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+
+ // Frame 1 arrives normally.
+ link.push_notification(
+ AUDIO_SIGNAL_UUID.as_u128(),
+ phone_frame(&mut phone, ty::NOTES_SYNC, b"first"),
+ );
+ let got = raw_rx.recv().await.unwrap();
+ assert_eq!(
+ (got.peer_pub, got.ty, got.payload),
+ ([0u8; 32], ty::NOTES_SYNC, b"first".to_vec())
+ );
+
+ // Frame 2 is sealed and then THROWN AWAY — the link lost it. The phone's
+ // send nonce has advanced; the laptop's receive nonce has not.
+ let _lost = phone_frame(&mut phone, ty::NOTES_SYNC, b"lost");
+
+ // Frame 3 must still be delivered, by skipping the burnt nonce.
+ link.push_notification(
+ AUDIO_SIGNAL_UUID.as_u128(),
+ phone_frame(&mut phone, ty::NOTES_SYNC, b"third"),
+ );
+ let got = tokio::time::timeout(Duration::from_secs(2), raw_rx.recv())
+ .await
+ .expect("must not hang")
+ .expect("must not close");
+ assert_eq!((got.ty, got.payload), (ty::NOTES_SYNC, b"third".to_vec()));
+ assert!(
+ RESYNC_EVENTS.load(Ordering::Relaxed) > before,
+ "the recovery must be counted, not silent"
+ );
+
+ handle.abort();
+ }
+
+ /// A frame type outside the allow-list is dropped without being opened, so
+ /// an unexpected type can never consume a nonce or reach a feature channel.
+ #[tokio::test]
+ async fn an_unlisted_frame_type_is_ignored() {
+ let link = Arc::new(FakeGattLink::new(vec![AUDIO_SIGNAL_UUID.as_u128()]));
+ let (laptop, mut phone) = transport_pair();
+ let (mut raw_rx, handle) = spawn_listener(Arc::clone(&link), laptop);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+
+ // PAIRING_HANDSHAKE has no business on this characteristic.
+ link.push_notification(
+ AUDIO_SIGNAL_UUID.as_u128(),
+ phone_frame(&mut phone, ty::PAIRING_HANDSHAKE, b"nope"),
+ );
+ // …and a legitimate frame right after still lands, because the rejected
+ // one never touched the cipher.
+ link.push_notification(
+ AUDIO_SIGNAL_UUID.as_u128(),
+ phone_frame(&mut phone, ty::NOTES_SYNC, b"ok"),
+ );
+ let got = raw_rx.recv().await.unwrap();
+ assert_eq!(
+ (got.peer_pub, got.ty, got.payload),
+ ([0u8; 32], ty::NOTES_SYNC, b"ok".to_vec())
+ );
+ handle.abort();
+ }
+
+ /// A peer without the characteristic is refused up front rather than
+ /// failing later on the first write.
+ #[tokio::test]
+ async fn a_peer_without_the_characteristic_is_refused() {
+ let link = FakeGattLink::new(vec![]);
+ let (laptop, _phone) = transport_pair();
+ let err = run_listener(
+ &link,
+ Arc::new(Mutex::new(laptop)),
+ [0u8; 32],
+ None,
+ None, None, None, None, None, None, None, None, None, None, None, None,
+ None, None,
+ )
+ .await
+ .expect_err("no AUDIO_SIGNAL characteristic");
+ assert!(err.contains("AUDIO_SIGNAL"), "{err}");
+ }
+}
diff --git a/linux/daemon/src/core/ble/frame.rs b/linux/daemon/src/core/ble/frame.rs
index 1d8a4bf..a49aa64 100644
--- a/linux/daemon/src/core/ble/frame.rs
+++ b/linux/daemon/src/core/ble/frame.rs
@@ -14,6 +14,25 @@
/// Header size in bytes.
pub const FRAME_HEADER_LEN: usize = 4;
+/// An additive feature frame, opened and forwarded to whichever module owns it.
+///
+/// A named struct rather than a tuple because `sub` had to be threaded through
+/// for the filesystem ops (`FS_REQ` carries its op there), and a signature with
+/// two adjacent `u8`s is a transposition waiting to happen — `(ty, sub)` and
+/// `(sub, ty)` both compile and only one is right.
+///
+/// `peer_pub` is part of it because a frame's meaning can depend on WHO sent
+/// it: `PEER_HANDOFF` says "you are no longer my active peer", which is
+/// unactionable without knowing whose statement it is, and an `FS_REQ` handle
+/// belongs to one peer's table and not another's.
+#[derive(Debug, Clone)]
+pub struct RawFrame {
+ pub peer_pub: [u8; 32],
+ pub ty: u8,
+ pub sub: u8,
+ pub payload: Vec,
+}
+
/// Per spec §11. Larger frames are a `bad-frame` error. Sized to admit a
/// 48 KiB LAN file-transfer chunk + AEAD tag (BLE notifies stay MTU-small
/// regardless; the `length` field is u16 so the hard ceiling is 65535).
@@ -184,6 +203,46 @@ pub mod ty {
pub const PHONE_FILES: u8 = 0x4F;
pub const FRAG: u8 = 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 ([`sub::HANDOFF_RELEASE`] etc.) and
+ /// the AEAD payload an optional UTF-8 successor name for the UI (peer-
+ /// supplied, so sanitise before display).
+ ///
+ /// Additive by design: both sides log-and-ignore an unknown frame type, so
+ /// a peer without this build is unaffected. Mirrors Kotlin
+ /// `FrameType.PEER_HANDOFF`.
+ // 0x54, not 0x4F: upstream took 0x4F for PHONE_FILES while this was on a
+ // branch, and two meanings for one type byte is a protocol that cannot be
+ // read. This one moved because it is the one that has never shipped —
+ // nothing but these two repositories has ever sent it. Sits just past the
+ // FS block below, keeping this branch's additions contiguous.
+ pub const PEER_HANDOFF: u8 = 0x54;
+ /// Ranged-filesystem request. `sub` carries the op (`core::fs_proto::op`),
+ /// the payload a JSON request — plus a binary byte tail for `WRITE`.
+ ///
+ /// **Bidirectional and symmetric**: both peers serve these and both send
+ /// them. The laptop browses the phone's storage with the same frames the
+ /// phone browses the laptop's, so neither the frame nor its handler names a
+ /// side. See `docs/design/file-browsing.md`.
+ ///
+ /// Additive: an unknown frame type is logged and ignored on both sides, so
+ /// a peer without this build simply never answers and the requester times
+ /// out. Mirrors Kotlin `FrameType.FS_REQ`.
+ pub const FS_REQ: u8 = 0x50;
+ /// Successful non-data reply to an `FS_REQ` — directory page, stat, open
+ /// result or write ack. Carries `core::fs_proto::FsReply` JSON. Mirrors
+ /// Kotlin `FrameType.FS_META`.
+ pub const FS_META: u8 = 0x51;
+ /// Read result: `[id u32 BE][offset u64 BE][flags u8][bytes]`. Binary
+ /// rather than JSON because base64 would cost 33% on the hottest path in
+ /// the protocol. Mirrors Kotlin `FrameType.FS_DATA`.
+ pub const FS_DATA: u8 = 0x52;
+ /// A definite failure for one request id (`core::fs_proto::FsErr` JSON).
+ /// Every failing op answers with one: a file manager blocked on a read
+ /// that will never be answered is the worst outcome in this feature, so
+ /// silence is never a valid response. Mirrors Kotlin `FrameType.FS_ERR`.
+ pub const FS_ERR: u8 = 0x53;
pub const ERROR: u8 = 0x7F;
}
@@ -193,6 +252,18 @@ pub mod sub {
pub const PONG: u8 = 0x02;
pub const ECHO_REQUEST: u8 = 0x01;
pub const ECHO_RESPONSE: u8 = 0x02;
+ /// `PEER_HANDOFF` kinds. Mirror Kotlin `FrameSub.HANDOFF_*`.
+ ///
+ /// RELEASE: "you are no longer my active peer" — sent by the side handing
+ /// ownership over, so the receiver stops presenting itself as connected
+ /// instead of discovering it on the next contact.
+ pub const HANDOFF_RELEASE: u8 = 0x01;
+ /// BUSY: refused, another peer is already active. Explicit so a rejected
+ /// peer can back off; silence is indistinguishable from packet loss and
+ /// invites a retry loop against the phone's single GATT link.
+ pub const HANDOFF_BUSY: u8 = 0x02;
+ /// CLAIM: request to become the active peer.
+ pub const HANDOFF_CLAIM: u8 = 0x03;
}
#[derive(Debug, Clone, PartialEq, Eq)]
diff --git a/linux/daemon/src/core/ble/mod.rs b/linux/daemon/src/core/ble/mod.rs
index ea3164c..59cdd95 100644
--- a/linux/daemon/src/core/ble/mod.rs
+++ b/linux/daemon/src/core/ble/mod.rs
@@ -1,9 +1,26 @@
//! BLE constants, advertisement payload codec, and platform-side BLE
//! integration per spec §5 and §10.
+// `frame` is the WIRE PROTOCOL: frame types, subtypes and chunk headers, shared
+// byte-for-byte with the phone and used by the LAN transport too (see
+// `lan::tcp_client`, which reassembles bulk-sync datasets by BLE frame type).
+// It is pure Rust and must build everywhere — the phone cannot tell the two
+// laptops apart, and `shared/vectors/` exists to keep it that way.
+pub mod frame;
+
+// The post-handshake event stream: AEAD-sealed frames in both directions over
+// the AUDIO_SIGNAL characteristic, with nonce resync when the link drops one.
+// Platform-neutral — it speaks `core::platform::GattLink`, so the same dispatch
+// and the same resync run over BlueZ, over WinRT, or over a test fake.
pub mod audio_signal;
+
+// Everything below is BlueZ over D-Bus: the central-role transport that
+// carries those frames on Linux. A second OS brings its own transport (WinRT
+// `BluetoothLEDevice`) behind `core::platform::BleCentral` and reuses both
+// `frame` and `audio_signal` unchanged.
+#[cfg(target_os = "linux")]
pub mod client;
-pub mod frame;
+#[cfg(target_os = "linux")]
pub mod scanner;
/// V1 protocol version byte. Receivers MUST reject other versions (§5.2).
@@ -142,6 +159,39 @@ impl AdvPayload {
}
}
+/// Decode a **Service Data — 128-bit UUID** AD section (type `0x21`): sixteen
+/// bytes of service UUID followed by the service data itself.
+///
+/// Returns the payload only when the UUID is ours AND the payload passes the
+/// §5.2 filter. `None` covers a foreign advert, a truncated section, and a
+/// malformed payload alike, because a scanner's only useful question is "is
+/// this a Vortex peer worth looking at".
+///
+/// The UUID in an AD structure is **little-endian** — reversed from the printed
+/// form. That reversal is the whole reason this is a function with a test
+/// rather than a comparison written inline at a call site.
+///
+/// Platforms that hand back parsed service data (BlueZ gives a UUID→bytes map)
+/// go straight to [`AdvPayload::decode`]; this is for the ones that hand over
+/// raw AD sections, as WinRT does.
+pub fn decode_service_data_128(section: &[u8]) -> Option {
+ if section.len() < 16 {
+ return None;
+ }
+ let (uuid_le, payload) = section.split_at(16);
+ let mut be = [0u8; 16];
+ for (i, b) in uuid_le.iter().rev().enumerate() {
+ be[i] = *b;
+ }
+ if uuid::Uuid::from_bytes(be) != VORTEX_SERVICE_UUID {
+ return None;
+ }
+ AdvPayload::decode(payload).ok()
+}
+
+/// AD type for Service Data with a 128-bit UUID (Core Spec Supplement §1.11).
+pub const AD_TYPE_SERVICE_DATA_128: u8 = 0x21;
+
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AdvDecodeError {
WrongLength(usize),
@@ -253,6 +303,54 @@ mod tests {
));
}
+ /// Build the AD section a phone actually emits: little-endian UUID, then
+ /// the 10-byte payload.
+ fn service_data_section(payload: [u8; ADV_PAYLOAD_LEN]) -> Vec {
+ let mut v: Vec = VORTEX_SERVICE_UUID.as_bytes().iter().rev().copied().collect();
+ v.extend_from_slice(&payload);
+ v
+ }
+
+ #[test]
+ fn decodes_a_service_data_section_from_raw_ad_bytes() {
+ let token = [0x11u8; 8];
+ let section = service_data_section(AdvPayload::trusted_presence(token).encode());
+ let decoded = decode_service_data_128(§ion).expect("ours");
+ assert!(decoded.flags.is_trusted_presence());
+ assert_eq!(decoded.payload_8, token);
+ }
+
+ /// The UUID is little-endian on air. Feeding it big-endian must NOT match,
+ /// or a scanner would silently depend on which way round the platform
+ /// happened to hand the bytes over.
+ #[test]
+ fn a_big_endian_uuid_does_not_match() {
+ let mut section: Vec = VORTEX_SERVICE_UUID.as_bytes().to_vec();
+ section.extend_from_slice(&AdvPayload::pairable([0; 8]).encode());
+ assert!(decode_service_data_128(§ion).is_none());
+ }
+
+ #[test]
+ fn rejects_foreign_short_and_malformed_sections() {
+ // Someone else's service data.
+ let mut foreign: Vec = uuid::uuid!("00001234-0000-1000-8000-00805f9b34fb")
+ .as_bytes()
+ .iter()
+ .rev()
+ .copied()
+ .collect();
+ foreign.extend_from_slice(&AdvPayload::pairable([0; 8]).encode());
+ assert!(decode_service_data_128(&foreign).is_none());
+
+ // Truncated before the UUID even ends.
+ assert!(decode_service_data_128(&[0u8; 8]).is_none());
+
+ // Ours, but the payload fails the §5.2 filter (both mode bits set).
+ let mut bad = AdvPayload::pairable([0; 8]).encode();
+ bad[1] = 0x03;
+ assert!(decode_service_data_128(&service_data_section(bad)).is_none());
+ }
+
#[test]
fn rejects_no_mode_set() {
let mut bytes = AdvPayload::pairable([0; 8]).encode();
diff --git a/linux/daemon/src/core/clipboard_mirror.rs b/linux/daemon/src/core/clipboard_mirror.rs
index ef30895..d988227 100644
--- a/linux/daemon/src/core/clipboard_mirror.rs
+++ b/linux/daemon/src/core/clipboard_mirror.rs
@@ -17,11 +17,6 @@ pub const MAX_CLIPBOARD_TEXT_CHARS: usize = 65_536;
/// frame stays under the BLE notify MTU (same reason images are chunked).
pub const MAX_SINGLE_FRAME_TEXT_BYTES: usize = 400;
-/// Max bytes for a phone→laptop FILE pulled over LAN (reliable TCP). Files
-/// ride the same offer+pull path as images but can be much larger; this bounds
-/// memory and transfer time. ~64 MiB covers documents, photos, short clips.
-pub const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024;
-
/// "Blob available, pull it over LAN" signal (phone→laptop). The laptop fetches
/// the bytes by `token` via the next bulk-sync (served as CLIPBOARD_IMAGE
/// chunks). When `name`/`mime` are EMPTY it's a clipboard IMAGE (PNG); when set
@@ -75,13 +70,23 @@ impl ClipboardImageOffer {
/// share, or a kind this build does not know — lands in the root as
/// shares always have.
pub fn subdir(&self) -> Option<&'static str> {
- match self.kind.as_str() {
- "screenshot" => Some("Phone/Screenshots"),
- "photo" => Some("Phone/Photos"),
- "screen_recording" => Some("Phone/Screen recordings"),
- "video" => Some("Phone/Videos"),
- _ => None,
- }
+ subdir_for_kind(&self.kind)
+ }
+}
+
+/// The same table, reachable without an [`Offer`].
+///
+/// The ranged-read puller receives a kind string off the queue rather than the
+/// whole offer, and must file a capture in the same place the bulk path did —
+/// two copies of this table would be two chances to disagree about where a
+/// screenshot lives.
+pub fn subdir_for_kind(kind: &str) -> Option<&'static str> {
+ match kind {
+ "screenshot" => Some("Phone/Screenshots"),
+ "photo" => Some("Phone/Photos"),
+ "screen_recording" => Some("Phone/Screen recordings"),
+ "video" => Some("Phone/Videos"),
+ _ => None,
}
}
diff --git a/linux/daemon/src/core/crypto/noise.rs b/linux/daemon/src/core/crypto/noise.rs
index eab7701..56b1598 100644
--- a/linux/daemon/src/core/crypto/noise.rs
+++ b/linux/daemon/src/core/crypto/noise.rs
@@ -4,11 +4,16 @@
use snow::{params::NoiseParams, Builder};
+/// Re-exported so a consumer can NAME the state a completed handshake hands
+/// back without taking its own `snow` dependency. Two copies of snow in one
+/// build are two incompatible `TransportState`s, and the mismatch surfaces as a
+/// baffling type error at an API boundary rather than as a version conflict.
+pub use snow::TransportState;
+
pub const NOISE_XX: &str = "Noise_XX_25519_ChaChaPoly_SHA256";
/// The reconnect pattern — used by `run_ik_deterministic` for the test
/// vector AND at runtime. Runtime additionally mixes the Pairwise Reconnect
-/// Secret into the prologue (see
-/// [`crate::core::pairing::reconnect::prologue_with_prs`]), which is what
+/// Secret into the prologue (see [`prologue_with_prs`]), which is what
/// keeps a reconnect authenticated after a long-term static-key compromise.
/// That is the goal `Noise_IKpsk2` would serve; the prologue route reaches it
/// without a pattern the Android-side Noise library does not implement.
@@ -17,6 +22,26 @@ pub const NOISE_IK: &str = "Noise_IK_25519_ChaChaPoly_SHA256";
pub const PROLOGUE_XX: &[u8] = b"vortex/v1/pairing";
pub const PROLOGUE_IK: &[u8] = b"vortex/v1/reconnect";
+/// Build the IK prologue with the Pairwise Reconnect Secret mixed in.
+///
+/// We extend the base prologue with the 32-byte PRS so that any wrong-PRS
+/// attempt by an attacker who has compromised only the long-term static
+/// private key fails AEAD verification on msg1's `s` decryption. This achieves
+/// the same security goal as Noise_IKpsk2_... — binding reconnect to BOTH
+/// static keys AND the prior pairing transcript — without requiring a Noise
+/// pattern that the Android-side library does not yet implement.
+///
+/// Lives HERE, with the prologue it extends, rather than in the BLE reconnect
+/// module it was first written in: both transports need it (`lan::tcp_client`
+/// runs the same IK over TCP) and it is normative wire material, so it must
+/// not sit behind a platform gate.
+pub(crate) fn prologue_with_prs(prs: &[u8; 32]) -> Vec {
+ let mut out = Vec::with_capacity(PROLOGUE_IK.len() + 32);
+ out.extend_from_slice(PROLOGUE_IK);
+ out.extend_from_slice(prs);
+ out
+}
+
/// Result of a deterministic handshake run.
#[derive(Debug, Clone)]
pub struct HandshakeResult {
@@ -209,4 +234,25 @@ mod tests {
let result = initiator.read_message(&buf[..len], &mut tmp);
assert!(result.is_err(), "mismatched prologue must fail AEAD");
}
+
+ /// The reconnect prologue is normative wire material: the phone builds the
+ /// same bytes, and a mismatch fails AEAD on msg1 rather than producing a
+ /// readable error. Pin the layout — base prologue, then the raw 32-byte
+ /// PRS, nothing else — so a refactor can't silently reorder or pad it.
+ #[test]
+ fn ik_prologue_is_base_then_prs() {
+ let prs = [0xAB; 32];
+ let p = prologue_with_prs(&prs);
+ assert_eq!(p.len(), PROLOGUE_IK.len() + 32);
+ assert_eq!(&p[..PROLOGUE_IK.len()], PROLOGUE_IK);
+ assert_eq!(&p[PROLOGUE_IK.len()..], &prs[..]);
+ assert_eq!(&p[..PROLOGUE_IK.len()], b"vortex/v1/reconnect");
+ }
+
+ /// A different PRS must give a different prologue — that difference IS the
+ /// binding to the prior pairing transcript.
+ #[test]
+ fn a_different_prs_gives_a_different_prologue() {
+ assert_ne!(prologue_with_prs(&[1u8; 32]), prologue_with_prs(&[2u8; 32]));
+ }
}
diff --git a/linux/daemon/src/core/fs_lan.rs b/linux/daemon/src/core/fs_lan.rs
new file mode 100644
index 0000000..5fc829f
--- /dev/null
+++ b/linux/daemon/src/core/fs_lan.rs
@@ -0,0 +1,218 @@
+//! A LAN transport for the filesystem protocol.
+//!
+//! BLE carries these frames today and works, but at 30-40 KiB/s (measured) —
+//! fine for a directory listing, hopeless for content. Design doc §6 is
+//! explicit that content streams over Wi-Fi and BLE stays for metadata and
+//! wake-up, so this opens a TCP+IK session and pushes the same frames down it.
+//!
+//! Two properties make it worth a dedicated session rather than reusing the
+//! heartbeat:
+//!
+//! * **It stays open.** The heartbeat connects, syncs and disconnects every
+//! ~13 s. Filesystem work is bursty and correlated — a fetch is one OPEN, N
+//! READs and a CLOSE — and paying a TCP connect plus an IK handshake
+//! (~300 ms on this link) per request would cost more than the reads.
+//! * **No fragmentation.** A BLE notify caps at 512 bytes, so a 48 KiB read is
+//! 96 fragments paced 10 ms apart. Over TCP the same read is one frame.
+//!
+//! The session is otherwise deliberately dumb: it moves frames and knows
+//! nothing about ops, ids or handles. The caller keeps all of that, which is
+//! what lets the same `fs_link` client sit on either transport.
+
+use std::net::SocketAddr;
+use std::sync::Arc;
+use std::time::Duration;
+
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::net::TcpStream;
+use tokio::sync::Mutex;
+use tokio::time::timeout;
+
+use crate::core::ble::frame::{ty, Frame, FRAME_HEADER_LEN, MAX_FRAME_PAYLOAD};
+use crate::core::crypto::x25519::X25519SecBytes;
+
+/// Budget for the handshake. Same shape as the audio session's: generous
+/// enough for a sleepy phone, short enough that a dead address fails fast and
+/// the caller can fall back to BLE while the user is still watching.
+const IK_STEP_TIMEOUT: Duration = Duration::from_secs(8);
+
+/// Writes one sealed frame onto the session.
+pub type FsLanWriter = Arc<
+ dyn Fn(u8, u8, Vec) -> futures::future::BoxFuture<'static, Result<(), String>>
+ + Send
+ + Sync,
+>;
+
+/// Open a TCP+IK session for filesystem frames.
+///
+/// On success the read loop runs until the socket closes or a frame fails to
+/// open, handing every decrypted frame to `on_frame` and calling `on_closed`
+/// exactly once at the end — the caller uses that to drop its cached writer so
+/// the next request reopens or falls back rather than writing into a dead
+/// socket.
+#[allow(clippy::too_many_arguments)]
+pub async fn open_session(
+ addr: SocketAddr,
+ static_priv: &X25519SecBytes,
+ peer_static_pub: &[u8; 32],
+ prs: &[u8; 32],
+ local_counter: u64,
+ on_frame: Arc,
+ on_closed: Arc,
+) -> Result {
+ let mut stream = timeout(IK_STEP_TIMEOUT, TcpStream::connect(addr))
+ .await
+ .map_err(|_| "tcp connect timeout".to_string())?
+ .map_err(|e| format!("tcp connect: {e}"))?;
+ // Filesystem traffic is many small round trips (a stat per icon) plus a
+ // few large ones. Nagle would sit on the small ones waiting for company.
+ let _ = stream.set_nodelay(true);
+
+ let mut handshake =
+ crate::core::lan::tcp_client::build_ik_initiator(static_priv, peer_static_pub, prs)
+ .map_err(|e| format!("noise build: {e}"))?;
+ let mut buf = vec![0u8; 1024];
+ let mut tmp = vec![0u8; 1024];
+
+ let n = handshake
+ .write_message(&local_counter.to_be_bytes(), &mut buf)
+ .map_err(|e| format!("noise write msg1: {e}"))?;
+ write_frame(&mut stream, &Frame::new(ty::RECONNECT_HANDSHAKE, 0x01, buf[..n].to_vec())).await?;
+
+ let msg2 = timeout(IK_STEP_TIMEOUT, read_frame_capped(&mut stream, 128))
+ .await
+ .map_err(|_| "msg2 timeout".to_string())??;
+ if msg2.ty != ty::RECONNECT_HANDSHAKE || msg2.sub != 0x02 {
+ return Err(format!("unexpected msg2 ty=0x{:02x}", msg2.ty));
+ }
+ handshake
+ .read_message(&msg2.payload, &mut tmp)
+ .map_err(|e| format!("noise read msg2: {e}"))?;
+
+ // The peer's static must be the one we trusted at pair time. Checked
+ // before a single filesystem frame goes out: this session can be asked to
+ // read the user's files, so "who is on the other end" is not a question to
+ // answer optimistically.
+ if handshake
+ .get_remote_static()
+ .ok_or_else(|| "no remote static after IK".to_string())?
+ != peer_static_pub
+ {
+ return Err("peer static mismatch".to_string());
+ }
+
+ let transport = Arc::new(Mutex::new(
+ handshake
+ .into_transport_mode()
+ .map_err(|e| format!("transport mode: {e}"))?,
+ ));
+
+ let (mut reader, writer_half) = stream.into_split();
+ let writer_half = Arc::new(Mutex::new(writer_half));
+
+ // Read loop. Owns the receive side of the cipher, so it never contends
+ // with the writer for it beyond the shared mutex.
+ {
+ let transport = transport.clone();
+ tokio::spawn(async move {
+ loop {
+ match read_sealed(&mut reader, &transport).await {
+ Ok(Some(frame)) => on_frame(frame),
+ Ok(None) => {
+ tracing::info!("fs-lan: peer closed the session");
+ break;
+ }
+ Err(e) => {
+ tracing::warn!("fs-lan: read loop ended: {e}");
+ break;
+ }
+ }
+ }
+ on_closed();
+ });
+ }
+
+ let writer: FsLanWriter = Arc::new(move |ty_byte: u8, sub: u8, plain: Vec| {
+ let transport = transport.clone();
+ let writer_half = writer_half.clone();
+ Box::pin(async move {
+ if plain.len() + 16 > MAX_FRAME_PAYLOAD {
+ return Err(format!("frame too large: {}", plain.len()));
+ }
+ let mut out = vec![0u8; plain.len() + 16];
+ let n = {
+ let mut t = transport.lock().await;
+ t.write_message(&plain, &mut out)
+ .map_err(|e| format!("aead seal: {e}"))?
+ };
+ let bytes = Frame::new(ty_byte, sub, out[..n].to_vec()).encode();
+ let mut w = writer_half.lock().await;
+ w.write_all(&bytes).await.map_err(|e| format!("tcp write: {e}"))?;
+ w.flush().await.map_err(|e| format!("tcp flush: {e}"))?;
+ Ok(())
+ })
+ });
+
+ tracing::info!(%addr, "fs-lan: session up");
+ Ok(writer)
+}
+
+async fn write_frame(stream: &mut TcpStream, frame: &Frame) -> Result<(), String> {
+ let bytes = frame.encode();
+ stream.write_all(&bytes).await.map_err(|e| format!("tcp write: {e}"))?;
+ stream.flush().await.map_err(|e| format!("tcp flush: {e}"))?;
+ Ok(())
+}
+
+async fn read_frame_capped(stream: &mut TcpStream, cap: usize) -> Result {
+ let cap = cap.min(MAX_FRAME_PAYLOAD);
+ let mut header = [0u8; FRAME_HEADER_LEN];
+ stream
+ .read_exact(&mut header)
+ .await
+ .map_err(|e| format!("tcp read header: {e}"))?;
+ let length = u16::from_be_bytes([header[2], header[3]]) as usize;
+ if length > cap {
+ return Err(format!("oversize frame {length}"));
+ }
+ let mut full = vec![0u8; FRAME_HEADER_LEN + length];
+ full[..FRAME_HEADER_LEN].copy_from_slice(&header);
+ if length > 0 {
+ stream
+ .read_exact(&mut full[FRAME_HEADER_LEN..])
+ .await
+ .map_err(|e| format!("tcp read body: {e}"))?;
+ }
+ Frame::decode(&full).map_err(|e| format!("frame decode: {e}"))
+}
+
+/// Read one frame and AEAD-open it. `Ok(None)` is a clean EOF.
+async fn read_sealed(
+ reader: &mut tokio::net::tcp::OwnedReadHalf,
+ transport: &Arc>,
+) -> Result