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, String> { + let mut header = [0u8; FRAME_HEADER_LEN]; + match reader.read_exact(&mut header).await { + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None), + Err(e) => return Err(format!("tcp read header: {e}")), + } + let length = u16::from_be_bytes([header[2], header[3]]) as usize; + if length > MAX_FRAME_PAYLOAD { + return Err(format!("oversize frame {length}")); + } + let mut body = vec![0u8; length]; + if length > 0 { + reader + .read_exact(&mut body) + .await + .map_err(|e| format!("tcp read body: {e}"))?; + } + let mut plain = vec![0u8; length.max(16)]; + let n = { + let mut t = transport.lock().await; + t.read_message(&body, &mut plain) + .map_err(|e| format!("aead open: {e}"))? + }; + Ok(Some(Frame::new(header[0], header[1], plain[..n].to_vec()))) +} diff --git a/linux/daemon/src/core/fs_private.rs b/linux/daemon/src/core/fs_private.rs index 54aef2d..a57cced 100644 --- a/linux/daemon/src/core/fs_private.rs +++ b/linux/daemon/src/core/fs_private.rs @@ -1,19 +1,35 @@ //! Owner-only filesystem helpers for the on-disk mirror caches. //! -//! Everything under `~/.cache/vortex/` carries phone-private data (SMS -//! bodies, contacts, call history, app icons). These helpers make sure the -//! directory is 0700 and every file 0600 so other local users can't read -//! them — including repairing permissions left behind by older builds that -//! wrote with the default umask. +//! Everything under the cache root carries phone-private data (SMS bodies, +//! contacts, call history, app icons), so the directory and every file in it +//! must be readable by this user and nobody else — including repairing +//! permissions left behind by older builds that wrote with the default umask. +//! +//! # The two platforms do not offer the same guarantee +//! +//! On Unix this is exact: 0700 on the directory, 0600 on each file, set +//! explicitly rather than left to the umask. +//! +//! On Windows there is no mode to set. A file under the user's profile inherits +//! that profile's ACL, which already excludes other standard users — but grants +//! `Administrators` and `SYSTEM`. That is weaker than 0600 (where root is the +//! only equivalent) and it is *inherited*, so it holds only as long as the path +//! really is inside the profile. Tightening it means writing an explicit DACL +//! with `SetNamedSecurityInfoW`; until that exists, [`write_private`] on Windows +//! is "as private as the user's profile" and no more. Callers storing anything +//! stronger than mirror data must not rely on it. (Identity and peer keys do +//! not: they live in Secret Service / Credential Manager via +//! [`crate::core::storage`], never here.) use std::fs; use std::io; -use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt}; use std::path::Path; -/// Create `dir` (and parents) owner-only. If it already exists, tighten its -/// mode to 0700 — this repairs caches written by older builds. +/// Create `dir` (and parents) owner-only. If it already exists, tighten it — +/// this repairs caches written by older builds. +#[cfg(unix)] pub fn create_private_dir(dir: &Path) -> io::Result<()> { + use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; match fs::DirBuilder::new().recursive(true).mode(0o700).create(dir) { Ok(()) => {} Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {} @@ -24,13 +40,25 @@ pub fn create_private_dir(dir: &Path) -> io::Result<()> { fs::set_permissions(dir, fs::Permissions::from_mode(0o700)) } +/// Create `dir` (and parents), inheriting the user profile's ACL. +/// +/// TODO: `SetNamedSecurityInfoW` with an explicit owner-only DACL and +/// `PROTECTED_DACL_SECURITY_INFORMATION` to stop inheritance. See the module +/// docs for what is and isn't guaranteed until then. +#[cfg(windows)] +pub fn create_private_dir(dir: &Path) -> io::Result<()> { + fs::create_dir_all(dir) +} + /// Write `bytes` to `path` with mode 0600, creating the parent dir 0700. /// Truncates an existing file and tightens its mode too. +#[cfg(unix)] pub fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> { + use std::io::Write; + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; if let Some(parent) = path.parent() { create_private_dir(parent)?; } - use std::io::Write; let mut f = fs::OpenOptions::new() .write(true) .create(true) @@ -42,39 +70,78 @@ pub fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> { f.write_all(bytes) } +/// Write `bytes` to `path`, creating the parent dir, both inheriting the user +/// profile's ACL. See the module docs: this is weaker than the Unix path. +#[cfg(windows)] +pub fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> { + use std::io::Write; + if let Some(parent) = path.parent() { + create_private_dir(parent)?; + } + let mut f = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(path)?; + f.write_all(bytes) +} + #[cfg(test)] mod tests { use super::*; - use std::os::unix::fs::PermissionsExt; - fn mode_of(p: &Path) -> u32 { - fs::metadata(p).unwrap().permissions().mode() & 0o777 + /// A fresh per-test directory under the system temp dir. + fn scratch(tag: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("vortex-fsp-{tag}-{}", std::process::id())) } + /// Holds on both platforms: the write lands, parents are created, and a + /// second write replaces rather than appends. #[test] - fn dir_and_file_are_owner_only() { - let base = std::env::temp_dir().join(format!("vortex-fsp-{}", std::process::id())); - let dir = base.join("nested"); - let file = dir.join("data.json"); - write_private(&file, b"x").unwrap(); - assert_eq!(mode_of(&dir), 0o700); - assert_eq!(mode_of(&file), 0o600); + fn writes_through_missing_parents_and_replaces_content() { + let base = scratch("rw"); + let file = base.join("nested").join("data.json"); + write_private(&file, b"old").unwrap(); + assert_eq!(fs::read(&file).unwrap(), b"old"); + write_private(&file, b"new").unwrap(); + assert_eq!(fs::read(&file).unwrap(), b"new"); let _ = fs::remove_dir_all(&base); } - #[test] - fn repairs_existing_loose_permissions() { - let base = std::env::temp_dir().join(format!("vortex-fsp-fix-{}", std::process::id())); - fs::create_dir_all(&base).unwrap(); - fs::set_permissions(&base, fs::Permissions::from_mode(0o755)).unwrap(); - let file = base.join("data.json"); - fs::write(&file, b"old").unwrap(); - fs::set_permissions(&file, fs::Permissions::from_mode(0o644)).unwrap(); + #[cfg(unix)] + mod unix { + use super::*; + use std::os::unix::fs::PermissionsExt; - write_private(&file, b"new").unwrap(); - assert_eq!(mode_of(&base), 0o700); - assert_eq!(mode_of(&file), 0o600); - assert_eq!(fs::read(&file).unwrap(), b"new"); - let _ = fs::remove_dir_all(&base); + fn mode_of(p: &Path) -> u32 { + fs::metadata(p).unwrap().permissions().mode() & 0o777 + } + + #[test] + fn dir_and_file_are_owner_only() { + let base = scratch("modes"); + let dir = base.join("nested"); + let file = dir.join("data.json"); + write_private(&file, b"x").unwrap(); + assert_eq!(mode_of(&dir), 0o700); + assert_eq!(mode_of(&file), 0o600); + let _ = fs::remove_dir_all(&base); + } + + #[test] + fn repairs_existing_loose_permissions() { + let base = scratch("fix"); + fs::create_dir_all(&base).unwrap(); + fs::set_permissions(&base, fs::Permissions::from_mode(0o755)).unwrap(); + let file = base.join("data.json"); + fs::write(&file, b"old").unwrap(); + fs::set_permissions(&file, fs::Permissions::from_mode(0o644)).unwrap(); + + write_private(&file, b"new").unwrap(); + assert_eq!(mode_of(&base), 0o700); + assert_eq!(mode_of(&file), 0o600); + assert_eq!(fs::read(&file).unwrap(), b"new"); + let _ = fs::remove_dir_all(&base); + } } } diff --git a/linux/daemon/src/core/fs_proto.rs b/linux/daemon/src/core/fs_proto.rs new file mode 100644 index 0000000..4fc4c57 --- /dev/null +++ b/linux/daemon/src/core/fs_proto.rs @@ -0,0 +1,655 @@ +//! Ranged filesystem protocol — the one primitive underneath both file +//! browsing and large-file transfer (design doc `docs/design/file-browsing.md`). +//! +//! # Why this exists +//! +//! Every file transfer in Vortex today buffers a whole file in memory on the +//! sending side: the phone stashes bytes in `ClipboardBlobStore` keyed by a +//! content token and the laptop pulls the lot in one go. That is what made an +//! 835 MB share an `OutOfMemoryError`, and it is why a 64 MB `MAX_FILE_BYTES` +//! cap exists at all. A file manager needs the same missing primitive for a +//! different reason — Explorer, Dolphin and every thumbnailer issue ranged +//! reads constantly. So: +//! +//! ```text +//! READ(handle, offset, len) -> bytes +//! ``` +//! +//! buys both features at once, and the cap disappears as a side effect rather +//! than as a separate change. +//! +//! # Symmetry +//! +//! Unlike the original design sketch, this protocol is **bidirectional**: both +//! peers serve it and both consume it. The laptop browses the phone's storage, +//! and the phone browses the laptop's — same ops, same frames, same code paths. +//! Nothing here names a side. A "server" is whichever peer received the request. +//! +//! # Framing +//! +//! Four frame types ride the existing Noise-sealed app-data channel; `sub` +//! carries the op, so an unknown op is rejected without parsing a payload: +//! +//! | Frame | Payload | +//! |---|---| +//! | `FS_REQ` | `sub` = [`op`], JSON request (or JSON + binary tail for WRITE) | +//! | `FS_META` | JSON [`FsReply`] — listing, stat, open result, write ack | +//! | `FS_DATA` | binary read result: `[id u32][offset u64][flags u8][bytes]` | +//! | `FS_ERR` | JSON [`FsErr`] — a definite, immediate failure | +//! +//! Read results are binary rather than JSON on purpose: base64 would cost 33% +//! on the single hottest path in the protocol. +//! +//! Requests carry an `id` and are **pipelined, not serialised**. A file manager +//! stats everything in view at once; a request/response lock would feel broken. +//! Replies are correlated by `id` and may arrive out of order. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +/// Op codes, carried in the frame's `sub` byte. Authoritative for routing — the +/// JSON payload is NOT self-tagged, so these must agree with the payload shape. +/// Mirrors Kotlin `FsOp`. +pub mod op { + pub const LIST: u8 = 0x01; + pub const STAT: u8 = 0x02; + pub const OPEN: u8 = 0x03; + pub const READ: u8 = 0x04; + pub const WRITE: u8 = 0x05; + pub const CLOSE: u8 = 0x06; + pub const SETMETA: u8 = 0x07; +} + +/// Error codes. Deliberately errno-shaped: both mount adapters (FUSE, ProjFS) +/// have to turn these back into OS errors, and inventing a private vocabulary +/// would mean two lossy translations instead of none. +/// +/// Mirrors Kotlin `FsCode`. +pub mod code { + /// No such file or directory. + pub const NOENT: i32 = 2; + /// Permission denied — including "outside every served root". + pub const ACCES: i32 = 13; + /// I/O error. + pub const IO: i32 = 5; + /// Bad handle: unknown, expired, or closed. + pub const BADF: i32 = 9; + /// Invalid argument (bad range, malformed path, oversized read). + pub const INVAL: i32 = 22; + /// Not supported — an op that is defined and wired but deliberately not + /// implemented. Answered explicitly, never dropped: a stub that looks like + /// a timeout is worse than an honest refusal, and a file manager blocked on + /// a dead read is the worst outcome of all. + pub const NOTSUP: i32 = 95; + /// Is a directory (read attempted on one). + pub const ISDIR: i32 = 21; + /// Read-only: the path resolves under a root that does not allow writes. + pub const ROFS: i32 = 30; +} + +/// Bytes per `FS_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 of the exercise. +/// +/// Sits inside `MAX_FRAME_PAYLOAD` (63 KiB) with room for the 13-byte +/// [`FS_DATA`](self) header and the AEAD tag. +pub const MAX_READ_LEN: u32 = 48 * 1024; + +/// Entries per `FS_LIST` page. A 10,000-entry folder must not be one frame. +pub const LIST_PAGE: usize = 256; + +/// Binary header on an `FS_DATA` payload: id(4) + offset(8) + flags(1). +pub const DATA_HEADER_LEN: usize = 13; + +/// `FS_DATA` flag: this reply reaches end-of-file, so the consumer can stop +/// reading without a further round trip. +pub const FLAG_EOF: u8 = 0x01; + +// --------------------------------------------------------------------------- +// Requests +// --------------------------------------------------------------------------- + +/// List one page of a directory. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct ListReq { + pub id: u32, + pub path: String, + /// Opaque resume point from the previous page's [`FsReply::List::cursor`]. + /// 0 starts at the beginning. + #[serde(default)] + pub cursor: u32, +} + +/// Stat one path. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct StatReq { + pub id: u32, + pub path: String, +} + +/// Open a path and get a handle back. +/// +/// Handles rather than paths for reads: resolving a path per read is a TOCTOU +/// problem, and under Android's SAF it is also slow. Open once, read many. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct OpenReq { + pub id: u32, + pub path: String, + /// Open for writing (creating or truncating as needed). Refused with + /// [`code::ROFS`] unless the path resolves under a writable root. + #[serde(default)] + pub write: bool, +} + +/// Read `len` bytes at `offset`. A short reply is normal (end of file, or the +/// server chose a smaller slice); it is not an error and not necessarily EOF — +/// check [`FLAG_EOF`]. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct ReadReq { + pub id: u32, + pub handle: u64, + pub offset: u64, + pub len: u32, +} + +/// Write at `offset`. The bytes ride a binary tail after the JSON header — see +/// [`encode_write`] — rather than inside it, for the same reason reads are +/// binary. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct WriteReq { + pub id: u32, + pub handle: u64, + pub offset: u64, +} + +/// Release a handle. Best-effort: a server may drop handles on its own (process +/// death, idle expiry), so a `CLOSE` for an unknown handle is success, not +/// [`code::BADF`] — the consumer's intent is already satisfied. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct CloseReq { + pub id: u32, + pub handle: u64, +} + +/// Set metadata / rename. Wired and answered, implementation optional per side. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct SetMetaReq { + pub id: u32, + pub path: String, + /// Seconds since the Unix epoch. + #[serde(default)] + pub mtime: Option, + /// New *name* (not a path) within the same directory. + #[serde(default)] + pub rename_to: Option, +} + +// --------------------------------------------------------------------------- +// Replies +// --------------------------------------------------------------------------- + +/// One directory entry, or the result of a stat. +/// +/// Deliberately minimal: a file manager needs name, kind, size and mtime to +/// render a row, and every extra field is bytes on a link that may be BLE. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct FsEntry { + /// Display name. Base name for a listing; for a stat, the base name of the + /// stat'd path. NOT an address — see [`FsEntry::path`]. + pub name: String, + /// Opaque, server-defined token that addresses this entry in a later + /// request. + /// + /// On a real filesystem this is the absolute path, but under Android's SAF + /// it is a document URI — a name is simply not addressable there. So a + /// consumer must send this back verbatim and must never construct a child + /// address by joining [`FsEntry::name`] onto its parent. + #[serde(default)] + pub path: String, + #[serde(default)] + pub is_dir: bool, + #[serde(default)] + pub size: u64, + /// Seconds since the Unix epoch, or 0 when the server cannot tell. + #[serde(default)] + pub mtime: i64, + /// The server will refuse writes here. Advisory — used to grey out UI, not + /// to enforce anything; enforcement is the server's job. + #[serde(default)] + pub readonly: bool, +} + +/// A successful non-data reply, carried as JSON in an `FS_META` frame. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum FsReply { + List { + id: u32, + entries: Vec, + /// Resume point for the next page, or `None` when the listing is + /// complete. `Some` always means "call again" — never a guess. + #[serde(default)] + cursor: Option, + }, + Stat { + id: u32, + entry: FsEntry, + }, + Open { + id: u32, + handle: u64, + /// Size at open time, so a consumer can plan its reads in one round + /// trip instead of open-then-stat. + size: u64, + #[serde(default)] + readonly: bool, + }, + Wrote { + id: u32, + bytes: u32, + }, + /// Generic success for ops with nothing to report (`CLOSE`, `SETMETA`). + Ok { + id: u32, + }, +} + +impl FsReply { + /// The request this reply answers. + pub fn id(&self) -> u32 { + match self { + FsReply::List { id, .. } + | FsReply::Stat { id, .. } + | FsReply::Open { id, .. } + | FsReply::Wrote { id, .. } + | FsReply::Ok { id } => *id, + } + } +} + +/// A definite failure. Every failing op sends one of these — silence is never +/// an answer, because the far side cannot distinguish it from a lost frame. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct FsErr { + pub id: u32, + /// One of [`code`]. + pub code: i32, + /// Short, human-readable context. Never contains a full path: paths are + /// user data and this may be logged. + #[serde(default)] + pub msg: String, +} + +impl FsErr { + pub fn new(id: u32, code: i32, msg: impl Into) -> Self { + Self { + id, + code, + msg: msg.into(), + } + } +} + +// --------------------------------------------------------------------------- +// Binary framings +// --------------------------------------------------------------------------- + +/// Build an `FS_DATA` payload: `[id u32 BE][offset u64 BE][flags u8][bytes]`. +pub fn encode_data(id: u32, offset: u64, eof: bool, bytes: &[u8]) -> Vec { + let mut out = Vec::with_capacity(DATA_HEADER_LEN + bytes.len()); + out.extend_from_slice(&id.to_be_bytes()); + out.extend_from_slice(&offset.to_be_bytes()); + out.push(if eof { FLAG_EOF } else { 0 }); + out.extend_from_slice(bytes); + out +} + +/// An owned read result, so a consumer is not tied to the frame buffer's +/// lifetime. Mirrors Kotlin `FsData`. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct FsData { + pub id: u32, + pub offset: u64, + pub eof: bool, + pub bytes: Vec, +} + +/// Parse an `FS_DATA` payload into `(id, offset, eof, bytes)`. +pub fn decode_data(p: &[u8]) -> Option<(u32, u64, bool, &[u8])> { + if p.len() < DATA_HEADER_LEN { + return None; + } + let id = u32::from_be_bytes(p[0..4].try_into().ok()?); + let offset = u64::from_be_bytes(p[4..12].try_into().ok()?); + let eof = p[12] & FLAG_EOF != 0; + Some((id, offset, eof, &p[DATA_HEADER_LEN..])) +} + +/// Build an `FS_REQ`/`WRITE` payload: `[json_len u16 BE][json][bytes]`. +pub fn encode_write(req: &WriteReq, bytes: &[u8]) -> Vec { + let json = serde_json::to_vec(req).unwrap_or_default(); + let mut out = Vec::with_capacity(2 + json.len() + bytes.len()); + out.extend_from_slice(&(json.len() as u16).to_be_bytes()); + out.extend_from_slice(&json); + out.extend_from_slice(bytes); + out +} + +/// Parse an `FS_REQ`/`WRITE` payload into its header and byte tail. +pub fn decode_write(p: &[u8]) -> Option<(WriteReq, &[u8])> { + if p.len() < 2 { + return None; + } + let n = u16::from_be_bytes([p[0], p[1]]) as usize; + // `2 + n` cannot overflow (n is a u16) but can exceed the payload if the + // frame was truncated — that must be a decode failure, not a panic. + let end = 2usize.checked_add(n)?; + if p.len() < end { + return None; + } + let req: WriteReq = serde_json::from_slice(&p[2..end]).ok()?; + Some((req, &p[end..])) +} + +// --------------------------------------------------------------------------- +// Served roots +// --------------------------------------------------------------------------- + +/// One served root and whether it accepts writes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Root { + pub path: PathBuf, + pub writable: bool, +} + +/// The set of paths this device serves to a paired peer, and the gate every +/// path-taking op passes through. +/// +/// A phone that is paired is not thereby trusted with `~/.ssh` — pairing proves +/// identity, not authorisation. So the answer to "what may the peer see" is an +/// explicit allowlist rather than "whatever the daemon's user can read", and it +/// lives in a config file the user can widen to `/` deliberately if that is +/// what they want. +#[derive(Debug, Clone, Default)] +pub struct Roots { + roots: Vec, +} + +impl Roots { + pub fn new(roots: Vec) -> Self { + Self { roots } + } + + pub fn is_empty(&self) -> bool { + self.roots.is_empty() + } + + pub fn list(&self) -> &[Root] { + &self.roots + } + + /// Resolve a peer-supplied path, or refuse it. + /// + /// Canonicalises before comparing, so `..` traversal and symlinks that + /// point outside a root are rejected rather than merely discouraged. This + /// is the only place a peer-supplied path becomes a real one; everything + /// downstream may assume the result is inside a root. + /// + /// `for_write` additionally requires the matched root to be writable. + pub fn resolve(&self, path: &str, for_write: bool) -> Result { + if path.is_empty() { + return Err(code::INVAL); + } + let requested = PathBuf::from(path); + if !requested.is_absolute() { + return Err(code::INVAL); + } + // Canonicalise the deepest existing ancestor, then re-append the rest. + // A write may legitimately target a path that does not exist yet, so + // canonicalising the full path would refuse every file creation — but + // the *existing* prefix is what a symlink escape would have to go + // through, so checking that is sufficient. + let (existing, tail) = deepest_existing(&requested); + let canon = existing.canonicalize().map_err(|_| code::NOENT)?; + let full = if tail.as_os_str().is_empty() { + canon.clone() + } else { + canon.join(&tail) + }; + for root in &self.roots { + let Ok(croot) = root.path.canonicalize() else { + continue; + }; + if !full.starts_with(&croot) { + continue; + } + if for_write && !root.writable { + return Err(code::ROFS); + } + return Ok(full); + } + // Deliberately ACCES and not NOENT: "outside every root" is a policy + // refusal, and reporting NOENT would let a peer probe for the existence + // of paths it is not allowed to see. + Err(code::ACCES) + } + + /// Whether writes are allowed anywhere. Used to advertise capability. + pub fn any_writable(&self) -> bool { + self.roots.iter().any(|r| r.writable) + } +} + +/// Split `p` into (deepest existing ancestor, remaining tail). +/// +/// Components are collected and joined at the end rather than prepended as we +/// go: `PathBuf::push("")` appends a separator, so building the tail +/// incrementally produced `out.bin/` for a single missing component. `resolve` +/// still accepted that — the prefix check passes — but the trailing slash means +/// "directory" to the OS, so every attempt to create a file failed with +/// `NotADirectory` well after the path had been blessed. +fn deepest_existing(p: &Path) -> (PathBuf, PathBuf) { + fn joined(parts: &[std::ffi::OsString]) -> PathBuf { + let mut tail = PathBuf::new(); + for part in parts.iter().rev() { + tail.push(part); + } + tail + } + let mut base = p.to_path_buf(); + let mut parts: Vec = Vec::new(); + loop { + if base.exists() { + return (base, joined(&parts)); + } + let Some(name) = base.file_name().map(|n| n.to_os_string()) else { + // Walked off the top without finding anything that exists. + return (base, joined(&parts)); + }; + parts.push(name); + if !base.pop() { + return (base, joined(&parts)); + } + } +} + +/// Parse the roots config. One path per line; `#` comments; blank lines +/// ignored. An optional `ro `/`rw ` prefix sets writability (default `ro`). +/// +/// Kept this dumb on purpose — the user was promised a file they could edit by +/// hand, and a hand-edited TOML/JSON that fails to parse would silently serve +/// nothing. +pub fn parse_roots(text: &str) -> Roots { + let mut out = Vec::new(); + for raw in text.lines() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + // Tokenise rather than strip a prefix: `strip_prefix("rw ")` fails on a + // bare "rw" line (the trailing space is already trimmed), which then + // fell through to the default arm and served a root literally named + // "rw". A path containing spaces still works — only an exact `rw`/`ro` + // first token is treated as a flag. + let mut parts = line.splitn(2, char::is_whitespace); + let first = parts.next().unwrap_or(""); + let remainder = parts.next().unwrap_or("").trim(); + let (writable, rest) = match first { + "rw" => (true, remainder), + "ro" => (false, remainder), + _ => (false, line), + }; + if rest.is_empty() { + continue; + } + out.push(Root { + path: PathBuf::from(rest), + writable, + }); + } + Roots::new(out) +} + +/// The default config file written on first run. +pub fn default_roots_file(home: &Path) -> String { + format!( + "# Folders this device serves to a paired phone.\n\ + #\n\ + # One path per line. Prefix with \"rw \" to allow writes (so the phone\n\ + # can upload into it), or \"ro \" for read-only. Default is read-only.\n\ + #\n\ + # Set this to \"rw /\" to serve the whole filesystem. Nothing here can\n\ + # exceed your own user's permissions, but note that a paired phone is\n\ + # then able to read anything you can — including SSH keys and browser\n\ + # profiles. Pairing proves which device it is, not that it should see\n\ + # everything.\n\ + #\n\ + # Paths are canonicalised before use, so \"..\" and symlinks that point\n\ + # outside a root are refused.\n\ + rw {}\n", + home.display() + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn data_round_trips_including_an_empty_tail() { + let enc = encode_data(7, 4096, true, b"hello"); + let (id, off, eof, bytes) = decode_data(&enc).expect("decodes"); + assert_eq!((id, off, eof, bytes), (7, 4096, true, b"hello".as_slice())); + + // A zero-length read at EOF is a normal reply, not a malformed frame. + let enc = encode_data(1, 0, true, b""); + let (_, _, eof, bytes) = decode_data(&enc).expect("decodes"); + assert!(eof && bytes.is_empty()); + } + + #[test] + fn a_truncated_data_frame_decodes_to_none_rather_than_panicking() { + assert!(decode_data(&[]).is_none()); + assert!(decode_data(&[0u8; DATA_HEADER_LEN - 1]).is_none()); + } + + #[test] + fn write_round_trips_and_a_lying_length_is_refused() { + let req = WriteReq { + id: 3, + handle: 9, + offset: 100, + }; + let enc = encode_write(&req, b"payload"); + let (got, bytes) = decode_write(&enc).expect("decodes"); + assert_eq!(got, req); + assert_eq!(bytes, b"payload"); + + // A header length past the end of the buffer must not panic or read + // out of bounds — a peer can send anything. + let mut bad = enc.clone(); + bad[0] = 0xff; + bad[1] = 0xff; + assert!(decode_write(&bad).is_none()); + } + + #[test] + fn roots_parse_with_comments_and_write_prefixes() { + let r = parse_roots( + "# comment\n\ + \n\ + rw /home/u\n\ + ro /srv/media\n\ + /plain/is/readonly\n\ + rw \n", + ); + assert_eq!(r.list().len(), 3); + assert!(r.list()[0].writable); + assert!(!r.list()[1].writable); + assert!(!r.list()[2].writable); + assert!(r.any_writable()); + } + + #[test] + fn resolve_refuses_traversal_relative_paths_and_unserved_roots() { + let tmp = std::env::temp_dir().join("vortex-fsproto-test-a"); + let inside = tmp.join("inside"); + std::fs::create_dir_all(&inside).expect("mkdir"); + let roots = Roots::new(vec![Root { + path: tmp.clone(), + writable: false, + }]); + + assert!(roots.resolve(inside.to_str().unwrap(), false).is_ok()); + // Relative paths are never accepted. + assert_eq!(roots.resolve("inside", false), Err(code::INVAL)); + assert_eq!(roots.resolve("", false), Err(code::INVAL)); + // Traversal out of the root canonicalises away and is then unserved. + let escape = format!("{}/../../etc", inside.display()); + assert_eq!(roots.resolve(&escape, false), Err(code::ACCES)); + // A read-only root refuses writes with ROFS, not a generic error, so + // the far side can tell "not allowed" from "broken". + assert_eq!( + roots.resolve(inside.to_str().unwrap(), true), + Err(code::ROFS) + ); + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn resolve_allows_a_not_yet_existing_file_under_a_writable_root() { + let tmp = std::env::temp_dir().join("vortex-fsproto-test-b"); + std::fs::create_dir_all(&tmp).expect("mkdir"); + let roots = Roots::new(vec![Root { + path: tmp.clone(), + writable: true, + }]); + + // An upload targets a path that does not exist yet; canonicalising the + // whole path would refuse every file creation. + let fresh = tmp.join("subdir-does-not-exist").join("new.bin"); + let got = roots.resolve(fresh.to_str().unwrap(), true); + assert!(got.is_ok(), "creation under a writable root must resolve"); + + // ...but the escape check still applies to the non-existent tail. + let escape = tmp.join("..").join("elsewhere").join("new.bin"); + assert_eq!(roots.resolve(escape.to_str().unwrap(), true), Err(code::ACCES)); + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn an_empty_root_set_serves_nothing() { + let roots = Roots::default(); + assert!(roots.is_empty()); + assert_eq!(roots.resolve("/etc/passwd", false), Err(code::ACCES)); + } + + #[test] + fn read_len_fits_a_frame_with_room_for_the_header_and_tag() { + let ceiling = super::super::ble::frame::MAX_FRAME_PAYLOAD; + assert!(MAX_READ_LEN as usize + DATA_HEADER_LEN + 16 <= ceiling); + } +} diff --git a/linux/daemon/src/core/fs_server.rs b/linux/daemon/src/core/fs_server.rs new file mode 100644 index 0000000..97bc930 --- /dev/null +++ b/linux/daemon/src/core/fs_server.rs @@ -0,0 +1,803 @@ +//! Serves the ranged-filesystem protocol against this machine's real files. +//! +//! The counterpart of [`crate::core::fs_proto`]: that module is the wire format +//! and the policy gate, this one does the I/O. Split so the gate is testable +//! without touching a disk, and so the Android side can mirror the wire format +//! without mirroring any of this. +//! +//! Everything here is synchronous and expected to run on a blocking thread — +//! reads are bounded to [`fs_proto::MAX_READ_LEN`], so no single call is long, +//! but a stalled network filesystem must not block an async executor. + +use std::collections::HashMap; +use std::fs::File; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::PathBuf; +use std::sync::Mutex; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use crate::core::fs_proto::{self as p, code, FsEntry, FsErr, FsReply}; + +/// An idle handle is dropped after this long. +/// +/// A consumer that dies mid-copy (a file manager killed, a mount unmounted) +/// never sends `CLOSE`, and an open file descriptor per abandoned read would +/// eventually exhaust the process. Reopening transparently is cheap; leaking is +/// not. +const HANDLE_IDLE_TIMEOUT: Duration = Duration::from_secs(300); + +/// Concurrent open handles. Bounded so a peer cannot exhaust our descriptors by +/// opening in a loop and never closing. +const MAX_HANDLES: usize = 64; + +struct Handle { + file: File, + writable: bool, + last_used: Instant, +} + +/// Open handles for one peer. +/// +/// Per-peer rather than global: with several paired phones, one peer's handle +/// ids must not address another's files. The multi-peer work made "whose +/// statement is this" a load-bearing question everywhere else in the codebase, +/// and a handle table is no different. +#[derive(Default)] +pub struct FsHandles { + inner: Mutex, +} + +#[derive(Default)] +struct Inner { + next: u64, + open: HashMap, +} + +impl FsHandles { + pub fn new() -> Self { + Self::default() + } + + fn insert(&self, h: Handle) -> Result { + let mut g = self.inner.lock().map_err(|_| code::IO)?; + prune(&mut g); + if g.open.len() >= MAX_HANDLES { + return Err(code::IO); + } + // Start at 1 so 0 is never a valid handle — it is the value a buggy + // consumer is most likely to send by accident. + g.next = g.next.wrapping_add(1).max(1); + let id = g.next; + g.open.insert(id, h); + Ok(id) + } + + /// Number of live handles. Diagnostics only. + pub fn len(&self) -> usize { + self.inner.lock().map(|g| g.open.len()).unwrap_or(0) + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + fn with(&self, id: u64, f: impl FnOnce(&mut Handle) -> Result) -> Result { + let mut g = self.inner.lock().map_err(|_| code::IO)?; + prune(&mut g); + let h = g.open.get_mut(&id).ok_or(code::BADF)?; + h.last_used = Instant::now(); + f(h) + } + + fn remove(&self, id: u64) { + if let Ok(mut g) = self.inner.lock() { + g.open.remove(&id); + } + } + + /// Drop every handle — called when a peer's session ends, so a reconnect + /// starts from a clean table rather than inheriting stale ids. + pub fn clear(&self) { + if let Ok(mut g) = self.inner.lock() { + g.open.clear(); + } + } +} + +fn prune(g: &mut Inner) { + let now = Instant::now(); + g.open + .retain(|_, h| now.duration_since(h.last_used) < HANDLE_IDLE_TIMEOUT); +} + +/// What a served op produced. The caller turns this into frames — this module +/// deliberately knows nothing about framing or transports. +pub enum Served { + /// Send as `FS_META`. + Meta(FsReply), + /// Send as `FS_DATA` — already includes its binary header. + Data(Vec), + /// Send as `FS_ERR`. + Err(FsErr), +} + +impl Served { + fn err(id: u32, c: i32, msg: &str) -> Self { + Served::Err(FsErr::new(id, c, msg)) + } +} + +/// Serve one request. +/// +/// `roots` is the policy gate; every path-taking op resolves through it, and a +/// path outside every root is refused before any I/O happens. +pub fn serve( + roots: &p::Roots, + handles: &FsHandles, + op: u8, + payload: &[u8], +) -> Served { + match op { + p::op::LIST => match serde_json::from_slice::(payload) { + Ok(r) => do_list(roots, &r), + Err(_) => Served::err(0, code::INVAL, "malformed LIST"), + }, + p::op::STAT => match serde_json::from_slice::(payload) { + Ok(r) => do_stat(roots, &r), + Err(_) => Served::err(0, code::INVAL, "malformed STAT"), + }, + p::op::OPEN => match serde_json::from_slice::(payload) { + Ok(r) => do_open(roots, handles, &r), + Err(_) => Served::err(0, code::INVAL, "malformed OPEN"), + }, + p::op::READ => match serde_json::from_slice::(payload) { + Ok(r) => do_read(handles, &r), + Err(_) => Served::err(0, code::INVAL, "malformed READ"), + }, + p::op::WRITE => match p::decode_write(payload) { + Some((r, bytes)) => do_write(handles, &r, bytes), + None => Served::err(0, code::INVAL, "malformed WRITE"), + }, + p::op::CLOSE => match serde_json::from_slice::(payload) { + Ok(r) => { + handles.remove(r.handle); + // Not BADF for an unknown handle: we expire handles ourselves, + // so "already gone" is exactly the state the caller wanted. + Served::Meta(FsReply::Ok { id: r.id }) + } + Err(_) => Served::err(0, code::INVAL, "malformed CLOSE"), + }, + p::op::SETMETA => match serde_json::from_slice::(payload) { + Ok(r) => do_setmeta(roots, &r), + Err(_) => Served::err(0, code::INVAL, "malformed SETMETA"), + }, + other => { + // Answer, do not drop. An unimplemented op that behaves like a + // timeout hangs the far side's file manager. + tracing::debug!("fs: unsupported op 0x{other:02x}"); + Served::err(0, code::NOTSUP, "unsupported op") + } + } +} + +fn do_list(roots: &p::Roots, r: &p::ListReq) -> Served { + // The empty path is the synthetic root: it lists the served roots + // themselves, so a peer can discover what it may see without being told + // the paths out of band. + if r.path == "/" || r.path.is_empty() { + if roots.list().len() != 1 { + let entries = roots + .list() + .iter() + .map(|root| FsEntry { + name: root.path.to_string_lossy().to_string(), + path: root.path.to_string_lossy().to_string(), + is_dir: true, + size: 0, + mtime: 0, + readonly: !root.writable, + }) + .collect(); + return Served::Meta(FsReply::List { + id: r.id, + entries, + cursor: None, + }); + } + // With exactly one root, a synthetic level above it would be a folder + // the user has to click through every time for no information. + } + // Which path to actually list. The single-root case above deliberately does + // NOT interpose a synthetic level, so the root's own path has to be + // substituted for the empty request here. Falling through with the empty + // path reached `resolve("")`, which is INVAL — so a peer asking for the + // root of a one-root device got "path refused" and could not browse at all. + // That is the DEFAULT configuration, and it is what the phone's browser hit + // on its first run. + let requested: String = if (r.path == "/" || r.path.is_empty()) && roots.list().len() == 1 { + roots.list()[0].path.to_string_lossy().to_string() + } else { + r.path.clone() + }; + let path = match resolve_or(roots, &requested, false, r.id) { + Ok(p) => p, + Err(s) => return s, + }; + let rd = match std::fs::read_dir(&path) { + Ok(rd) => rd, + Err(e) => return Served::Err(FsErr::new(r.id, io_code(&e), "read_dir failed")), + }; + // Stable order so pagination is coherent: `read_dir` order is unspecified + // and can differ between calls, which would make a cursor meaningless. + let mut names: Vec<_> = rd.filter_map(|e| e.ok().map(|e| e.file_name())).collect(); + names.sort(); + + let start = r.cursor as usize; + let end = (start + p::LIST_PAGE).min(names.len()); + let mut entries = Vec::with_capacity(end.saturating_sub(start)); + for name in &names[start.min(names.len())..end] { + let full = path.join(name); + // A single unreadable entry must not fail the whole page — a folder + // with one broken symlink would otherwise be unlistable. + let md = match std::fs::symlink_metadata(&full) { + Ok(md) => md, + Err(_) => continue, + }; + entries.push(FsEntry { + name: name.to_string_lossy().to_string(), + // The absolute path IS the address on a real filesystem, but the + // consumer must not assume that — it joins nothing itself. + path: full.to_string_lossy().to_string(), + is_dir: md.is_dir(), + size: if md.is_dir() { 0 } else { md.len() }, + mtime: mtime_secs(&md), + readonly: md.permissions().readonly(), + }); + } + Served::Meta(FsReply::List { + id: r.id, + entries, + cursor: if end < names.len() { + Some(end as u32) + } else { + None + }, + }) +} + +fn do_stat(roots: &p::Roots, r: &p::StatReq) -> Served { + let path = match resolve_or(roots, &r.path, false, r.id) { + Ok(p) => p, + Err(s) => return s, + }; + let md = match std::fs::metadata(&path) { + Ok(md) => md, + Err(e) => return Served::Err(FsErr::new(r.id, io_code(&e), "stat failed")), + }; + Served::Meta(FsReply::Stat { + id: r.id, + entry: FsEntry { + name: path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(), + path: path.to_string_lossy().to_string(), + is_dir: md.is_dir(), + size: if md.is_dir() { 0 } else { md.len() }, + mtime: mtime_secs(&md), + readonly: md.permissions().readonly(), + }, + }) +} + +fn do_open(roots: &p::Roots, handles: &FsHandles, r: &p::OpenReq) -> Served { + let path = match resolve_or(roots, &r.path, r.write, r.id) { + Ok(p) => p, + Err(s) => return s, + }; + if path.is_dir() { + return Served::err(r.id, code::ISDIR, "open on a directory"); + } + let file = if r.write { + // Create the parent chain: an upload into a folder the peer names is + // the whole point of a writable root, and requiring the directory to + // pre-exist would make that fail for no good reason. + if let Some(parent) = path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + return Served::Err(FsErr::new(r.id, io_code(&e), "mkdir failed")); + } + } + // NOT truncating: writes are ranged, so a consumer may legitimately + // fill a file out of order. Truncation is the caller's business. + std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&path) + } else { + File::open(&path) + }; + let file = match file { + Ok(f) => f, + Err(e) => return Served::Err(FsErr::new(r.id, io_code(&e), "open failed")), + }; + let size = file.metadata().map(|m| m.len()).unwrap_or(0); + let readonly = !r.write; + match handles.insert(Handle { + file, + writable: r.write, + last_used: Instant::now(), + }) { + Ok(handle) => Served::Meta(FsReply::Open { + id: r.id, + handle, + size, + readonly, + }), + Err(c) => Served::Err(FsErr::new(r.id, c, "too many open handles")), + } +} + +fn do_read(handles: &FsHandles, r: &p::ReadReq) -> Served { + if r.len == 0 || r.len > p::MAX_READ_LEN { + return Served::err(r.id, code::INVAL, "read length out of range"); + } + let out = handles.with(r.handle, |h| { + h.file.seek(SeekFrom::Start(r.offset)).map_err(|e| io_code(&e))?; + let mut buf = vec![0u8; r.len as usize]; + let mut got = 0usize; + // Loop: a single `read` is allowed to return short for reasons that + // have nothing to do with EOF, and a consumer that treated every short + // read as EOF would silently truncate files. + while got < buf.len() { + match h.file.read(&mut buf[got..]) { + Ok(0) => break, + Ok(n) => got += n, + Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(e) => return Err(io_code(&e)), + } + } + buf.truncate(got); + let size = h.file.metadata().map(|m| m.len()).unwrap_or(0); + let eof = r.offset.saturating_add(got as u64) >= size; + Ok((buf, eof)) + }); + match out { + Ok((buf, eof)) => Served::Data(p::encode_data(r.id, r.offset, eof, &buf)), + Err(c) => Served::Err(FsErr::new(r.id, c, "read failed")), + } +} + +fn do_write(handles: &FsHandles, r: &p::WriteReq, bytes: &[u8]) -> Served { + let out = handles.with(r.handle, |h| { + if !h.writable { + return Err(code::ROFS); + } + h.file.seek(SeekFrom::Start(r.offset)).map_err(|e| io_code(&e))?; + h.file.write_all(bytes).map_err(|e| io_code(&e))?; + Ok(bytes.len() as u32) + }); + match out { + Ok(n) => Served::Meta(FsReply::Wrote { id: r.id, bytes: n }), + Err(c) => Served::Err(FsErr::new(r.id, c, "write failed")), + } +} + +fn do_setmeta(roots: &p::Roots, r: &p::SetMetaReq) -> Served { + let path = match resolve_or(roots, &r.path, true, r.id) { + Ok(p) => p, + Err(s) => return s, + }; + if let Some(name) = &r.rename_to { + // A rename target is a NAME, not a path: accepting a path would let a + // peer move a file out of its root using the destination instead of + // the source, which the source-side gate above would never see. + if name.is_empty() + || name.contains('/') + || name.contains('\\') + || *name == ".." + || *name == "." + { + return Served::err(r.id, code::INVAL, "rename_to must be a bare name"); + } + let Some(parent) = path.parent() else { + return Served::err(r.id, code::INVAL, "no parent"); + }; + let dest = parent.join(name); + // Re-gate the destination: same root, and writable. + if let Err(c) = roots.resolve(&dest.to_string_lossy(), true) { + return Served::Err(FsErr::new(r.id, c, "rename destination refused")); + } + if let Err(e) = std::fs::rename(&path, &dest) { + return Served::Err(FsErr::new(r.id, io_code(&e), "rename failed")); + } + } + if r.mtime.is_some() { + // Honest refusal rather than a silent no-op: a consumer that believes + // it set an mtime will cache against a value that never changed. + return Served::err(r.id, code::NOTSUP, "mtime not supported"); + } + Served::Meta(FsReply::Ok { id: r.id }) +} + +fn resolve_or( + roots: &p::Roots, + path: &str, + for_write: bool, + id: u32, +) -> Result { + roots + .resolve(path, for_write) + // The path is NOT included in the message: it is user data and this + // goes to the log. + .map_err(|c| Served::Err(FsErr::new(id, c, "path refused"))) +} + +fn io_code(e: &std::io::Error) -> i32 { + use std::io::ErrorKind as K; + match e.kind() { + K::NotFound => code::NOENT, + K::PermissionDenied => code::ACCES, + K::InvalidInput | K::InvalidData => code::INVAL, + K::IsADirectory => code::ISDIR, + _ => code::IO, + } +} + +fn mtime_secs(md: &std::fs::Metadata) -> i64 { + md.modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// Load the served roots, writing the documented default on first run. +/// +/// Uses the platform seam's config root, so this is `~/.config/vortex` on Linux +/// and `%APPDATA%\Vortex` on Windows without a second code path. +pub fn load_roots() -> p::Roots { + let Some(dir) = crate::core::platform::paths().config() else { + tracing::warn!("fs: no config dir; serving nothing"); + return p::Roots::default(); + }; + let path = dir.join("fs-roots.conf"); + if let Ok(text) = std::fs::read_to_string(&path) { + let roots = p::parse_roots(&text); + tracing::info!("fs: serving {} root(s)", roots.list().len()); + return roots; + } + // First run: write the default so the file exists to be edited. Without a + // home directory there is nothing sensible to serve, so serve nothing + // rather than guess. + let Some(home) = home_dir() else { + tracing::warn!("fs: no home dir; serving nothing"); + return p::Roots::default(); + }; + let text = p::default_roots_file(&home); + if let Err(e) = std::fs::create_dir_all(&dir).and_then(|_| std::fs::write(&path, &text)) { + tracing::warn!("fs: couldn't write {}: {e}", path.display()); + } else { + tracing::info!("fs: wrote default roots config to {}", path.display()); + } + p::parse_roots(&text) +} + +fn home_dir() -> Option { + #[cfg(windows)] + { + std::env::var_os("USERPROFILE").map(PathBuf::from) + } + #[cfg(not(windows))] + { + std::env::var_os("HOME").map(PathBuf::from) + } +} + +/// Now, in seconds since the epoch. Used by callers building `FsEntry`s for +/// synthetic paths. +pub fn now_secs() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::fs_proto::Root; + + /// The empty path against a ONE-root device must list that root. + /// + /// It used to answer INVAL: the single-root branch skips the synthetic + /// listing (rightly — a level with one entry is a click for nothing) but + /// then resolved the still-empty path. One root is the default config, so + /// the default device could not be browsed at all. + #[test] + fn empty_path_lists_the_only_root() { + let dir = scratch("one-root"); + std::fs::write(dir.join("a.txt"), b"hi").expect("write"); + let roots = p::Roots::new(vec![Root { + path: dir.clone(), + writable: false, + }]); + let handles = FsHandles::new(); + let req = serde_json::to_vec(&serde_json::json!({"id": 1, "path": "", "cursor": 0})) + .expect("json"); + match serve(&roots, &handles, p::op::LIST, &req) { + Served::Meta(FsReply::List { entries, .. }) => { + assert!( + entries.iter().any(|e| e.name == "a.txt"), + "expected the root's contents, got {entries:?}" + ); + } + Served::Err(e) => panic!("expected a listing, got error {}: {}", e.code, e.msg), + _ => panic!("expected a listing"), + } + let _ = std::fs::remove_dir_all(&dir); + } + + fn scratch(name: &str) -> PathBuf { + let p = std::env::temp_dir().join(format!("vortex-fsserver-{name}")); + let _ = std::fs::remove_dir_all(&p); + std::fs::create_dir_all(&p).expect("mkdir"); + p + } + + fn rw_roots(dir: &PathBuf) -> p::Roots { + p::Roots::new(vec![Root { + path: dir.clone(), + writable: true, + }]) + } + + #[test] + fn open_read_reports_eof_and_survives_a_bounded_range() { + let dir = scratch("read"); + let file = dir.join("a.bin"); + std::fs::write(&file, b"0123456789").expect("write"); + let roots = rw_roots(&dir); + let handles = FsHandles::new(); + + let open = serde_json::to_vec(&p::OpenReq { + id: 1, + path: file.to_string_lossy().to_string(), + write: false, + }) + .unwrap(); + let handle = match serve(&roots, &handles, p::op::OPEN, &open) { + Served::Meta(FsReply::Open { handle, size, .. }) => { + assert_eq!(size, 10); + handle + } + _ => panic!("open failed"), + }; + assert_ne!(handle, 0, "0 must never be a valid handle"); + + // A mid-file read is not EOF... + let req = serde_json::to_vec(&p::ReadReq { + id: 2, + handle, + offset: 0, + len: 4, + }) + .unwrap(); + match serve(&roots, &handles, p::op::READ, &req) { + Served::Data(d) => { + let (id, off, eof, bytes) = p::decode_data(&d).expect("decodes"); + assert_eq!((id, off, eof, bytes), (2, 0, false, b"0123".as_slice())); + } + _ => panic!("read failed"), + } + // ...and a read that reaches the end is. + let req = serde_json::to_vec(&p::ReadReq { + id: 3, + handle, + offset: 6, + len: 100, + }) + .unwrap(); + match serve(&roots, &handles, p::op::READ, &req) { + Served::Data(d) => { + let (_, _, eof, bytes) = p::decode_data(&d).expect("decodes"); + assert!(eof); + assert_eq!(bytes, b"6789"); + } + _ => panic!("read failed"), + } + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_read_past_max_len_is_refused_rather_than_silently_clamped() { + let dir = scratch("maxlen"); + let file = dir.join("a.bin"); + std::fs::write(&file, b"x").expect("write"); + let roots = rw_roots(&dir); + let handles = FsHandles::new(); + let open = serde_json::to_vec(&p::OpenReq { + id: 1, + path: file.to_string_lossy().to_string(), + write: false, + }) + .unwrap(); + let handle = match serve(&roots, &handles, p::op::OPEN, &open) { + Served::Meta(FsReply::Open { handle, .. }) => handle, + _ => panic!("open"), + }; + let req = serde_json::to_vec(&p::ReadReq { + id: 2, + handle, + offset: 0, + len: p::MAX_READ_LEN + 1, + }) + .unwrap(); + // Clamping would make the reply's length silently disagree with the + // request, and a consumer computing offsets from what it asked for + // would corrupt the file. + match serve(&roots, &handles, p::op::READ, &req) { + Served::Err(e) => assert_eq!(e.code, code::INVAL), + _ => panic!("expected INVAL"), + } + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn ranged_writes_can_arrive_out_of_order() { + let dir = scratch("write"); + let file = dir.join("out.bin"); + let roots = rw_roots(&dir); + let handles = FsHandles::new(); + let open = serde_json::to_vec(&p::OpenReq { + id: 1, + path: file.to_string_lossy().to_string(), + write: true, + }) + .unwrap(); + let handle = match serve(&roots, &handles, p::op::OPEN, &open) { + Served::Meta(FsReply::Open { handle, .. }) => handle, + _ => panic!("open for write failed"), + }; + // Second half first: a pull that parallelises ranges must not depend on + // arrival order. + for (off, data) in [(4u64, b"DEFG".as_slice()), (0u64, b"ABCD".as_slice())] { + let pl = p::encode_write( + &p::WriteReq { + id: 2, + handle, + offset: off, + }, + data, + ); + match serve(&roots, &handles, p::op::WRITE, &pl) { + Served::Meta(FsReply::Wrote { bytes, .. }) => assert_eq!(bytes, 4), + _ => panic!("write failed"), + } + } + assert_eq!(std::fs::read(&file).expect("read back"), b"ABCDDEFG"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_read_only_root_refuses_open_for_write_with_rofs() { + let dir = scratch("ro"); + let roots = p::Roots::new(vec![Root { + path: dir.clone(), + writable: false, + }]); + let handles = FsHandles::new(); + let open = serde_json::to_vec(&p::OpenReq { + id: 1, + path: dir.join("nope.bin").to_string_lossy().to_string(), + write: true, + }) + .unwrap(); + match serve(&roots, &handles, p::op::OPEN, &open) { + Served::Err(e) => assert_eq!(e.code, code::ROFS), + _ => panic!("expected ROFS"), + } + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn listing_paginates_in_a_stable_order() { + let dir = scratch("list"); + for i in 0..(p::LIST_PAGE + 10) { + std::fs::write(dir.join(format!("f{i:05}")), b"").expect("write"); + } + let roots = rw_roots(&dir); + let handles = FsHandles::new(); + + let mut seen = Vec::new(); + let mut cursor = 0u32; + loop { + let req = serde_json::to_vec(&p::ListReq { + id: 1, + path: dir.to_string_lossy().to_string(), + cursor, + }) + .unwrap(); + match serve(&roots, &handles, p::op::LIST, &req) { + Served::Meta(FsReply::List { entries, cursor: c, .. }) => { + seen.extend(entries.into_iter().map(|e| e.name)); + match c { + Some(next) => cursor = next, + None => break, + } + } + _ => panic!("list failed"), + } + } + assert_eq!(seen.len(), p::LIST_PAGE + 10); + // No duplicates and no gaps: an unstable order would produce both. + let mut sorted = seen.clone(); + sorted.sort(); + sorted.dedup(); + assert_eq!(sorted.len(), seen.len(), "pagination lost or repeated entries"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_unknown_op_is_answered_notsup_rather_than_dropped() { + let roots = p::Roots::default(); + let handles = FsHandles::new(); + match serve(&roots, &handles, 0xEE, b"{}") { + Served::Err(e) => assert_eq!(e.code, code::NOTSUP), + _ => panic!("expected NOTSUP"), + } + } + + #[test] + fn a_stale_handle_is_badf_not_a_panic() { + let handles = FsHandles::new(); + let roots = p::Roots::default(); + let req = serde_json::to_vec(&p::ReadReq { + id: 1, + handle: 12345, + offset: 0, + len: 16, + }) + .unwrap(); + match serve(&roots, &handles, p::op::READ, &req) { + Served::Err(e) => assert_eq!(e.code, code::BADF), + _ => panic!("expected BADF"), + } + } + + #[test] + fn close_of_an_unknown_handle_succeeds() { + let handles = FsHandles::new(); + let roots = p::Roots::default(); + let req = serde_json::to_vec(&p::CloseReq { id: 1, handle: 99 }).unwrap(); + // We expire handles ourselves, so "already gone" is the caller's goal. + match serve(&roots, &handles, p::op::CLOSE, &req) { + Served::Meta(FsReply::Ok { id }) => assert_eq!(id, 1), + _ => panic!("close should succeed"), + } + } + + #[test] + fn rename_to_a_path_rather_than_a_name_is_refused() { + let dir = scratch("rename"); + let file = dir.join("a.txt"); + std::fs::write(&file, b"x").expect("write"); + let roots = rw_roots(&dir); + let handles = FsHandles::new(); + // A destination path would escape the gate applied to the source. + let req = serde_json::to_vec(&p::SetMetaReq { + id: 1, + path: file.to_string_lossy().to_string(), + mtime: None, + rename_to: Some("../../evil.txt".into()), + }) + .unwrap(); + match serve(&roots, &handles, p::op::SETMETA, &req) { + Served::Err(e) => assert_eq!(e.code, code::INVAL), + _ => panic!("expected INVAL"), + } + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/linux/daemon/src/core/handoff.rs b/linux/daemon/src/core/handoff.rs index 3bef0f0..18ef05d 100644 --- a/linux/daemon/src/core/handoff.rs +++ b/linux/daemon/src/core/handoff.rs @@ -21,6 +21,18 @@ pub struct HandoffEvent { /// pill the user clicks to open. #[serde(default)] pub open_now: bool, + /// Identity of an `open_now` request, so the laptop opens it EXACTLY once. + /// + /// The event also rides the phone's AppState snapshot as a LAN/BLE-STATE + /// backstop, and a snapshot is republished on every heartbeat. Without an + /// identity the consumer cannot tell "the share I already opened" from + /// "open this URL again", so it re-opened the browser every ~12s for as + /// long as the phone app stayed up. + /// + /// Empty on the live-read path, and from phone builds predating this field; + /// the consumer then falls back to deduping by URL. + #[serde(default)] + pub id: String, } impl HandoffEvent { diff --git a/linux/daemon/src/core/icon_cache.rs b/linux/daemon/src/core/icon_cache.rs index 5e2aa3d..f4a4269 100644 --- a/linux/daemon/src/core/icon_cache.rs +++ b/linux/daemon/src/core/icon_cache.rs @@ -26,9 +26,9 @@ pub fn parse_chunk(plain: &[u8]) -> Option<(String, u16, u16, Vec)> { } fn cache_dir() -> Option { - let mut p = PathBuf::from(std::env::var_os("HOME")?); - p.push(".cache/vortex/icons"); - Some(p) + // Seam, not `$HOME` — unset on Windows, where no mirrored app icon could + // ever be cached. Same `~/.cache/vortex/icons` on Linux. + Some(crate::core::platform::paths().cache()?.join("icons")) } /// Keep only safe path chars so a malformed package can't escape the dir. diff --git a/linux/daemon/src/core/identity/mod.rs b/linux/daemon/src/core/identity/mod.rs index 9fa6378..28729d1 100644 --- a/linux/daemon/src/core/identity/mod.rs +++ b/linux/daemon/src/core/identity/mod.rs @@ -17,11 +17,20 @@ use super::crypto::x25519::{public_from_private, X25519Pub, X25519Sec, X25519Sec pub const IDENTITY_VERSION: u8 = 0x01; /// `platform` byte (§3.1). +/// +/// LOCAL, despite living in a record with a spec section: the identity record +/// is written to this device's own secure storage and never leaves it. What the +/// peer sees is the `class` STRING in `AppState` ("laptop", "phone"), decoded by +/// its own `DeviceClass` — the phone's `Platform.fromByte` only ever parses the +/// record IT stored. So adding a variant here is not a wire change and cannot +/// make an existing phone reject us; the only reader of a `0x03` byte is a +/// Windows build reading back its own record. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Platform { Android = 0x01, Linux = 0x02, + Windows = 0x03, } impl Platform { @@ -32,6 +41,7 @@ impl Platform { match b { 0x01 => Some(Self::Android), 0x02 => Some(Self::Linux), + 0x03 => Some(Self::Windows), _ => None, } } @@ -97,6 +107,47 @@ impl IdentityRecord { debug_assert_eq!(out.len(), 90); out } + + /// Decode a 90-byte record written by [`Self::encode`]. + /// + /// Lives here, beside `encode`, rather than inside a storage backend: this + /// is the on-disk identity format, every backend reads the same bytes, and + /// a second copy of the offsets is a second place to get them wrong. + /// + /// Strict on length and version — a record that is not exactly what we + /// wrote is a corrupt keypair, and guessing at it would mean running with + /// a private key we cannot vouch for. + pub fn decode(bytes: &[u8]) -> Result { + if bytes.len() != 90 { + return Err(format!( + "stored identity has wrong length: {} (expected 90)", + bytes.len() + )); + } + let version = bytes[0]; + if version != IDENTITY_VERSION { + return Err(format!( + "stored identity has unknown version: {version:#04x}" + )); + } + let mut device_id = [0u8; 16]; + device_id.copy_from_slice(&bytes[1..17]); + let mut static_priv = [0u8; 32]; + static_priv.copy_from_slice(&bytes[17..49]); + let mut static_pub = [0u8; 32]; + static_pub.copy_from_slice(&bytes[49..81]); + let created_at = u64::from_be_bytes(bytes[81..89].try_into().unwrap()); + let platform = Platform::from_byte(bytes[89]) + .ok_or_else(|| format!("unknown platform byte: {}", bytes[89]))?; + Ok(Self { + version, + device_id, + static_priv: crate::core::crypto::x25519::X25519Sec(static_priv), + static_pub: crate::core::crypto::x25519::X25519Pub(static_pub), + created_at, + platform, + }) + } } fn current_unix_seconds() -> u64 { diff --git a/linux/daemon/src/core/lan/tcp_client.rs b/linux/daemon/src/core/lan/tcp_client.rs index 1ed5141..2dbdd96 100644 --- a/linux/daemon/src/core/lan/tcp_client.rs +++ b/linux/daemon/src/core/lan/tcp_client.rs @@ -189,7 +189,7 @@ pub(crate) fn build_ik_initiator( // achieves the same goal — wrong PRS yields different MixHash and // breaks AEAD verification on msg1. let params: NoiseParams = NOISE_IK.parse()?; - let prologue = crate::core::pairing::reconnect::prologue_with_prs(prs); + let prologue = crate::core::crypto::noise::prologue_with_prs(prs); Builder::new(params) .local_private_key(static_priv)? .remote_public_key(peer_static_pub)? diff --git a/linux/daemon/src/core/mod.rs b/linux/daemon/src/core/mod.rs index 512c807..43c2b84 100644 --- a/linux/daemon/src/core/mod.rs +++ b/linux/daemon/src/core/mod.rs @@ -1,32 +1,50 @@ pub mod appstate; +// The earbuds audio-op transport, driven by `audio_orchestrator` (PulseAudio + +// BlueZ). LAN-shaped but Linux-bound in purpose: a Windows build has no audio +// backend to hand the buds to yet. +#[cfg(target_os = "linux")] pub mod audio_lan_session; pub mod audio_op; +#[cfg(target_os = "linux")] pub mod audio_orchestrator; +#[cfg(target_os = "linux")] pub mod audio_route; pub mod audio_sink_cache; +#[cfg(target_os = "linux")] pub mod audio_switch; pub mod audio_switch_persistence; +#[cfg(target_os = "linux")] pub mod hogp; +#[cfg(target_os = "linux")] pub mod media_runtime; +#[cfg(target_os = "linux")] pub mod media_watch; pub mod ble; pub mod crypto; +#[cfg(target_os = "linux")] pub mod earbuds; +pub mod fs_lan; pub mod fs_private; pub mod earbuds_store; pub mod phone_files; pub mod smart_switch_store; pub mod clipboard_mirror; pub mod file_progress; +/// Ranged-filesystem wire protocol (see `docs/design/file-browsing.md`). +pub mod fs_proto; +/// Serves [`fs_proto`] against this machine's files. +pub mod fs_server; pub mod wifi_direct; pub mod outgoing_share; pub mod mirror_session; pub mod mirror_udp; pub mod mirror_tcp; pub mod notif_mirror; +#[cfg(target_os = "linux")] pub mod notification_display; pub mod notif_capturer; pub mod live_activity; +#[cfg(target_os = "linux")] pub mod live_activity_dbus; pub mod call_event; pub mod handoff; @@ -37,7 +55,10 @@ pub mod icon_cache; pub mod identity; pub mod lan; pub mod pairing; +pub mod platform; +#[cfg(target_os = "linux")] pub mod session_lock; pub mod status; pub mod storage; +#[cfg(target_os = "linux")] pub mod bt_hid; diff --git a/linux/daemon/src/core/pairing/handshake.rs b/linux/daemon/src/core/pairing/handshake.rs index b76d86a..0d66fa2 100644 --- a/linux/daemon/src/core/pairing/handshake.rs +++ b/linux/daemon/src/core/pairing/handshake.rs @@ -5,12 +5,12 @@ use std::time::Duration; -use futures::{pin_mut, StreamExt}; use snow::{params::NoiseParams, Builder, HandshakeState}; use tokio::time::timeout; use tracing::{debug, info}; -use crate::core::ble::client::{ClientError, VortexClient}; +use crate::core::ble::PAIRING_CONTROL_UUID; +use crate::core::platform::GattLink; use crate::core::ble::frame::{ty, Frame, FrameDecodeError}; use crate::core::crypto::derive::derive_prs; use crate::core::crypto::noise::{NOISE_XX, PROLOGUE_XX}; @@ -51,7 +51,10 @@ pub struct PairingOutcome { #[derive(Debug)] pub enum HandshakeError { Snow(snow::Error), - Client(ClientError), + /// The GATT link failed the write or subscribe. A `String` because + /// [`GattLink`] is the seam — BlueZ and WinRT share no error type, and + /// callers only log it. + Link(String), UnexpectedFrame { ty: u8, sub: u8 }, FrameDecode(FrameDecodeError), Timeout(&'static str), @@ -64,7 +67,7 @@ impl std::fmt::Display for HandshakeError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Snow(e) => write!(f, "noise: {e}"), - Self::Client(e) => write!(f, "ble client: {e}"), + Self::Link(e) => write!(f, "gatt link: {e}"), Self::UnexpectedFrame { ty, sub } => { write!(f, "unexpected frame type=0x{ty:02x} sub=0x{sub:02x}") } @@ -85,9 +88,9 @@ impl From for HandshakeError { } } -impl From for HandshakeError { - fn from(e: ClientError) -> Self { - Self::Client(e) +impl From for HandshakeError { + fn from(e: String) -> Self { + Self::Link(e) } } @@ -99,12 +102,22 @@ fn build_initiator(static_priv: &X25519SecBytes) -> Result Result<(), HandshakeError> { + link.write(PAIRING_CONTROL_UUID.as_u128(), &frame.encode(), false) + .await + .map_err(HandshakeError::Link) +} + +/// Run Noise XX against the peer on `link`, using the supplied static private /// key. Production callers pass the long-lived identity scalar. /// /// Bounded by `wait_per_step` for each notify step. pub async fn run_xx_initiator( - client: &VortexClient, + link: &dyn GattLink, static_priv: &X25519SecBytes, wait_per_step: Duration, ) -> Result { @@ -113,22 +126,18 @@ pub async fn run_xx_initiator( let mut payload_scratch = vec![0u8; 1024]; // Subscribe BEFORE writing msg1 so we don't miss the msg2 notification. - let notifies = client - .pairing_control - .notify() - .await - .map_err(ClientError::from)?; - pin_mut!(notifies); + let (tx, mut notifies) = tokio::sync::mpsc::unbounded_channel::>(); + link.subscribe(PAIRING_CONTROL_UUID.as_u128(), tx).await?; // ---- msg1 (initiator → responder) ---- let n = handshake.write_message(&[], &mut buffer)?; debug!(bytes = n, "noise xx msg1"); let frame = Frame::new(ty::PAIRING_HANDSHAKE, 0x01, buffer[..n].to_vec()); - client.write_pairing_control(&frame).await?; + write_pairing(link, &frame).await?; info!("→ msg1 sent ({} bytes)", n); // ---- msg2 (responder → initiator) ---- - let raw = timeout(wait_per_step, notifies.next()) + let raw = timeout(wait_per_step, notifies.recv()) .await .map_err(|_| HandshakeError::Timeout("msg2 notify"))? .ok_or(HandshakeError::Timeout("notify stream closed"))?; @@ -146,7 +155,7 @@ pub async fn run_xx_initiator( let n = handshake.write_message(&[], &mut buffer)?; debug!(bytes = n, "noise xx msg3"); let frame = Frame::new(ty::PAIRING_HANDSHAKE, 0x03, buffer[..n].to_vec()); - client.write_pairing_control(&frame).await?; + write_pairing(link, &frame).await?; info!("→ msg3 sent ({} bytes)", n); // Snow advances to transport mode after the third XX message; harvest @@ -176,7 +185,7 @@ pub async fn run_xx_initiator( /// derived PRS. Otherwise returns [`HandshakeError::LocalRejected`] or /// [`HandshakeError::PeerRejected`]. pub async fn run_pairing_initiator( - client: &VortexClient, + link: &dyn GattLink, static_priv: &X25519SecBytes, wait_per_step: Duration, decide: F, @@ -186,14 +195,11 @@ where F: FnOnce(&str) -> Fut, Fut: std::future::Future, { - // Subscribe to PairingControl notifications BEFORE writing msg1 and - // keep the stream alive across XX + approval. - let notifies = client - .pairing_control - .notify() - .await - .map_err(ClientError::from)?; - pin_mut!(notifies); + // Subscribe to PairingControl notifications BEFORE writing msg1 and keep + // the channel alive across XX + approval — the subscription outlives every + // step, so a notification can never land between two of them unheard. + let (tx, mut notifies) = tokio::sync::mpsc::unbounded_channel::>(); + link.subscribe(PAIRING_CONTROL_UUID.as_u128(), tx).await?; let mut handshake = build_initiator(static_priv)?; let mut buffer = vec![0u8; 1024]; @@ -202,11 +208,11 @@ where // ---- XX msg1 ---- let n = handshake.write_message(&[], &mut buffer)?; let frame = Frame::new(ty::PAIRING_HANDSHAKE, 0x01, buffer[..n].to_vec()); - client.write_pairing_control(&frame).await?; + write_pairing(link, &frame).await?; info!("→ msg1 sent ({} bytes)", n); // ---- XX msg2 ---- - let raw = timeout(wait_per_step, notifies.next()) + let raw = timeout(wait_per_step, notifies.recv()) .await .map_err(|_| HandshakeError::Timeout("msg2 notify"))? .ok_or(HandshakeError::Timeout("notify stream closed"))?; @@ -223,7 +229,7 @@ where // ---- XX msg3 ---- let n = handshake.write_message(&[], &mut buffer)?; let frame = Frame::new(ty::PAIRING_HANDSHAKE, 0x03, buffer[..n].to_vec()); - client.write_pairing_control(&frame).await?; + write_pairing(link, &frame).await?; info!("→ msg3 sent ({} bytes)", n); // Harvest XX outputs. @@ -270,9 +276,11 @@ where let mut approval_ct = vec![0u8; approval_plain.len() + 16]; let approval_ct_len = transport.write_message(&approval_plain, &mut approval_ct)?; approval_ct.truncate(approval_ct_len); - client - .write_pairing_control(&Frame::new(ty::PAIRING_APPROVAL, approval_sub, approval_ct)) - .await?; + write_pairing( + link, + &Frame::new(ty::PAIRING_APPROVAL, approval_sub, approval_ct), + ) + .await?; info!( "→ approval sent ({} ct bytes): {}", approval_ct_len, @@ -284,7 +292,7 @@ where } // Wait for peer's approval frame. - let raw = timeout(wait_per_step, notifies.next()) + let raw = timeout(wait_per_step, notifies.recv()) .await .map_err(|_| HandshakeError::Timeout("peer approval"))? .ok_or(HandshakeError::Timeout("notify stream closed"))?; @@ -334,7 +342,7 @@ const PEER_NAME_MAX_CHARS: usize = 64; /// * trim surrounding whitespace. /// /// Returns an empty string if everything was filtered out. -pub(crate) fn sanitize_peer_name(input: &str) -> String { +pub fn sanitize_peer_name(input: &str) -> String { let cleaned: String = input .chars() .filter(|c| { @@ -396,3 +404,161 @@ mod sanitize_tests { assert_eq!(sanitize_peer_name("\x00\x01\x02"), ""); } } + +#[cfg(test)] +mod link_tests { + use super::*; + use crate::core::crypto::noise::{NOISE_XX, PROLOGUE_XX}; + use crate::core::platform::FakeGattLink; + use snow::Builder; + + fn uuid() -> crate::core::platform::Uuid128 { + PAIRING_CONTROL_UUID.as_u128() + } + + fn fake() -> FakeGattLink { + FakeGattLink::new(vec![uuid()]) + } + + /// Wait until the initiator has written `n` frames, then return the last. + /// + /// Polling rather than a callback because `FakeGattLink` deliberately has + /// no notion of "react to a write" — it is a recorder, and the test drives + /// the peer side itself. + async fn nth_write(fake: &FakeGattLink, n: usize) -> Frame { + for _ in 0..200 { + { + let w = fake.writes.lock().unwrap(); + if w.len() >= n { + return Frame::decode(&w[n - 1].1).expect("well-formed frame"); + } + } + tokio::time::sleep(Duration::from_millis(1)).await; + } + panic!("initiator never wrote frame {n}"); + } + + /// A full XX pairing with dual approval, both sides in one test and no + /// radio anywhere: the test plays the phone. + /// + /// This is the flow that decides whether two devices trust each other + /// forever, and until the seam existed it could only be exercised with a + /// real phone, a real adapter and a human reading a code off a screen. The + /// property that matters most is the one asserted last: BOTH sides derive + /// the same SAS, because that is the only thing standing between the user + /// and a man in the middle. + #[tokio::test] + async fn a_full_pairing_completes_and_both_sides_agree_on_the_sas() { + let fake = fake(); + let responder_priv = [0x42u8; 32]; + + let phone = async { + let mut resp = Builder::new(NOISE_XX.parse().unwrap()) + .local_private_key(&responder_priv) + .unwrap() + .prologue(PROLOGUE_XX) + .unwrap() + .build_responder() + .unwrap(); + let mut scratch = vec![0u8; 1024]; + let mut out = vec![0u8; 1024]; + + // msg1 → msg2 + let msg1 = nth_write(&fake, 1).await; + assert_eq!((msg1.ty, msg1.sub), (ty::PAIRING_HANDSHAKE, 0x01)); + resp.read_message(&msg1.payload, &mut scratch).unwrap(); + let n = resp.write_message(&[], &mut out).unwrap(); + fake.push_notification( + uuid(), + Frame::new(ty::PAIRING_HANDSHAKE, 0x02, out[..n].to_vec()).encode(), + ); + + // msg3 completes XX + let msg3 = nth_write(&fake, 2).await; + assert_eq!((msg3.ty, msg3.sub), (ty::PAIRING_HANDSHAKE, 0x03)); + resp.read_message(&msg3.payload, &mut scratch).unwrap(); + let responder_hash = resp.get_handshake_hash().to_vec(); + let mut transport = resp.into_transport_mode().unwrap(); + + // The laptop's APPROVE arrives AEAD-wrapped; a tampered frame + // would fail right here, which is the point of wrapping it. + let approval = nth_write(&fake, 3).await; + assert_eq!(approval.ty, ty::PAIRING_APPROVAL); + assert_eq!(approval.sub, 0x01, "approve"); + let mut pt = vec![0u8; approval.payload.len()]; + let len = transport.read_message(&approval.payload, &mut pt).unwrap(); + assert_eq!(&pt[..len], b"test-laptop", "our name, decrypted"); + + // Answer with our own approval. + let mut ct = vec![0u8; 64]; + let n = transport.write_message(b"test-phone", &mut ct).unwrap(); + fake.push_notification( + uuid(), + Frame::new(ty::PAIRING_APPROVAL, 0x01, ct[..n].to_vec()).encode(), + ); + responder_hash + }; + + let laptop = run_pairing_initiator( + &fake, + &[0x11u8; 32], + Duration::from_secs(5), + |_sas| async { LocalDecision::Approve }, + Some("test-laptop"), + ); + + let (outcome, responder_hash) = tokio::join!(laptop, phone); + let outcome = outcome.expect("pairing should complete"); + + assert_eq!(outcome.peer_name.as_deref(), Some("test-phone")); + // The SAS the user compares is derived from the transcript, so both + // sides MUST agree — a mismatch is exactly what a MITM produces. + assert_eq!(outcome.xx.transcript_hash, responder_hash); + let (_, phone_sas) = crate::core::crypto::sas::derive_sas(&responder_hash); + assert_eq!(outcome.xx.sas_string, phone_sas); + assert_eq!(outcome.xx.sas_string.len(), 6); + // PRS comes from the transcript too, and only after both approved. + assert_eq!(outcome.prs, crate::core::crypto::derive::derive_prs(&responder_hash)); + } + + /// A local reject must still TELL the peer, then fail — otherwise the phone + /// sits waiting on a pairing the user already refused. + #[tokio::test] + async fn a_local_reject_sends_a_reject_frame_and_then_fails() { + let fake = fake(); + let phone = async { + let mut resp = Builder::new(NOISE_XX.parse().unwrap()) + .local_private_key(&[0x42u8; 32]) + .unwrap() + .prologue(PROLOGUE_XX) + .unwrap() + .build_responder() + .unwrap(); + let mut scratch = vec![0u8; 1024]; + let mut out = vec![0u8; 1024]; + let msg1 = nth_write(&fake, 1).await; + resp.read_message(&msg1.payload, &mut scratch).unwrap(); + let n = resp.write_message(&[], &mut out).unwrap(); + fake.push_notification( + uuid(), + Frame::new(ty::PAIRING_HANDSHAKE, 0x02, out[..n].to_vec()).encode(), + ); + nth_write(&fake, 3).await + }; + + let laptop = run_pairing_initiator( + &fake, + &[0x11u8; 32], + Duration::from_secs(5), + |_sas| async { LocalDecision::Reject }, + Some("test-laptop"), + ); + + let (outcome, reject_frame) = tokio::join!(laptop, phone); + assert!(matches!(outcome, Err(HandshakeError::LocalRejected))); + assert_eq!(reject_frame.ty, ty::PAIRING_APPROVAL); + assert_eq!(reject_frame.sub, 0x02, "reject"); + // No name leaks to a peer we just refused. + assert!(reject_frame.payload.len() <= 16, "empty plaintext + AEAD tag"); + } +} diff --git a/linux/daemon/src/core/pairing/mod.rs b/linux/daemon/src/core/pairing/mod.rs index bab6d39..843a603 100644 --- a/linux/daemon/src/core/pairing/mod.rs +++ b/linux/daemon/src/core/pairing/mod.rs @@ -1,5 +1,13 @@ //! Pairing orchestration per spec §6. pub mod backoff; + +// The XX pairing and IK reconnect handshakes. Platform-neutral: they take a +// `&dyn core::platform::GattLink`, so the same Noise state machine runs over +// BlueZ, over WinRT, and over a test fake with no radio at all. Only the +// transport differs, which is the whole point of the seam. +// +// (The LAN side is separate either way: `lan::tcp_client` runs its own IK over +// TCP.) pub mod handshake; pub mod reconnect; diff --git a/linux/daemon/src/core/pairing/reconnect.rs b/linux/daemon/src/core/pairing/reconnect.rs index c3c7d47..884b9bf 100644 --- a/linux/daemon/src/core/pairing/reconnect.rs +++ b/linux/daemon/src/core/pairing/reconnect.rs @@ -2,15 +2,15 @@ use std::time::Duration; -use futures::{pin_mut, StreamExt}; use rand::RngCore; use snow::{params::NoiseParams, Builder, HandshakeState, TransportState}; use tokio::time::timeout; use tracing::info; -use crate::core::ble::client::{ClientError, VortexClient}; use crate::core::ble::frame::{ty, Frame, FrameDecodeError}; -use crate::core::crypto::noise::{NOISE_IK, PROLOGUE_IK}; +use crate::core::ble::RECONNECT_CONTROL_UUID; +use crate::core::platform::GattLink; +use crate::core::crypto::noise::NOISE_IK; use crate::core::crypto::x25519::X25519SecBytes; #[derive(Debug)] @@ -34,7 +34,10 @@ pub struct ReconnectOutcome { #[derive(Debug)] pub enum ReconnectError { Snow(snow::Error), - Client(ClientError), + /// The GATT link failed the read, write or subscribe. A `String` because + /// [`GattLink`] is the seam: BlueZ and WinRT have nothing in common to + /// name here, and every caller only logs it. + Link(String), Timeout(&'static str), UnexpectedFrame { ty: u8, sub: u8 }, FrameDecode(FrameDecodeError), @@ -47,7 +50,7 @@ impl std::fmt::Display for ReconnectError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Snow(e) => write!(f, "noise: {e}"), - Self::Client(e) => write!(f, "ble client: {e}"), + Self::Link(e) => write!(f, "gatt link: {e}"), Self::Timeout(what) => write!(f, "timeout: {what}"), Self::UnexpectedFrame { ty, sub } => { write!(f, "unexpected frame type=0x{ty:02x} sub=0x{sub:02x}") @@ -68,12 +71,19 @@ impl From for ReconnectError { } } -impl From for ReconnectError { - fn from(e: ClientError) -> Self { - Self::Client(e) +impl From for ReconnectError { + fn from(e: String) -> Self { + Self::Link(e) } } +/// One frame to Reconnect Control, unacknowledged (§9.1). +async fn write_reconnect(link: &dyn GattLink, frame: &Frame) -> Result<(), ReconnectError> { + link.write(RECONNECT_CONTROL_UUID.as_u128(), &frame.encode(), false) + .await + .map_err(ReconnectError::Link) +} + fn build_ik_initiator( static_priv: &X25519SecBytes, peer_static_pub: &[u8; 32], @@ -83,27 +93,12 @@ fn build_ik_initiator( Builder::new(params) .local_private_key(static_priv)? .remote_public_key(peer_static_pub)? - .prologue(&prologue_with_prs(prs))? + .prologue(&crate::core::crypto::noise::prologue_with_prs(prs))? .build_initiator() } -/// 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. -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 -} -/// Run Noise IK against `client`'s peer using the local static identity, +/// Run Noise IK against the peer on `link`, using the local static identity, /// the trusted peer's static public key, and the Pairwise Reconnect /// Secret (mixed into the handshake prologue). /// @@ -115,20 +110,18 @@ pub(crate) fn prologue_with_prs(prs: &[u8; 32]) -> Vec { /// On success, the initiator follows up with a ping/pong liveness probe /// (frame `0x30/0x01` → `0x30/0x02`) before returning. pub async fn run_ik_initiator( - client: &VortexClient, + link: &dyn GattLink, static_priv: &X25519SecBytes, peer_static_pub: &[u8; 32], prs: &[u8; 32], local_counter: u64, wait_per_step: Duration, ) -> Result { - // Subscribe to Reconnect Control notifications BEFORE sending msg1. - let notifies = client - .reconnect_control - .notify() - .await - .map_err(ClientError::from)?; - pin_mut!(notifies); + // Subscribe to Reconnect Control notifications BEFORE sending msg1: the + // phone answers the moment it sees the write, and a notification that + // arrives before we are listening is simply gone. + let (tx, mut notifies) = tokio::sync::mpsc::unbounded_channel::>(); + link.subscribe(RECONNECT_CONTROL_UUID.as_u128(), tx).await?; let mut handshake = build_ik_initiator(static_priv, peer_static_pub, prs)?; let mut buffer = vec![0u8; 1024]; @@ -140,11 +133,15 @@ pub async fn run_ik_initiator( let counter_bytes = local_counter.to_be_bytes(); let n = handshake.write_message(&counter_bytes, &mut buffer)?; let frame = Frame::new(ty::RECONNECT_HANDSHAKE, 0x01, buffer[..n].to_vec()); - client.write_reconnect_control(&frame).await?; + // Write WITHOUT response throughout, per §9.1: the flow is driven by the + // notification each write provokes, so an ATT ack adds a round trip and no + // reliability. `write_reconnect_control` used to encode that choice; now + // the `false` does. + write_reconnect(link, &frame).await?; info!("→ IK msg1 sent ({} bytes, counter={local_counter})", n); // ---- IK msg2 ---- - let raw = timeout(wait_per_step, notifies.next()) + let raw = timeout(wait_per_step, notifies.recv()) .await .map_err(|_| ReconnectError::Timeout("msg2 notify"))? .ok_or(ReconnectError::Timeout("notify stream closed"))?; @@ -185,10 +182,10 @@ pub async fn run_ik_initiator( let mut nonce = [0u8; 8]; rand::rngs::OsRng.fill_bytes(&mut nonce); let ping = Frame::new(ty::TRANSPORT_KEEPALIVE, 0x01, nonce.to_vec()); - client.write_reconnect_control(&ping).await?; + write_reconnect(link, &ping).await?; info!("→ ping ({})", hex::encode(nonce)); - let raw = timeout(wait_per_step, notifies.next()) + let raw = timeout(wait_per_step, notifies.recv()) .await .map_err(|_| ReconnectError::Timeout("pong"))? .ok_or(ReconnectError::Timeout("notify stream closed"))?; @@ -212,3 +209,97 @@ pub async fn run_ik_initiator( transport: Some(transport), }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::platform::FakeGattLink; + + fn link() -> FakeGattLink { + FakeGattLink::new(vec![RECONNECT_CONTROL_UUID.as_u128()]) + } + + /// What IK msg1 must look like on the wire, without a phone in the room. + /// + /// This is the first test of this flow that has ever been possible: before + /// the seam it needed a BlueZ adapter and a real peer, so the frame type, + /// the characteristic and the write mode were only ever verified by the + /// handshake working end to end. + #[tokio::test] + async fn msg1_goes_out_unacknowledged_on_reconnect_control() { + let fake = link(); + let err = run_ik_initiator( + &fake, + &[7u8; 32], + &[9u8; 32], + &[3u8; 32], + 42, + Duration::from_millis(20), + ) + .await + .expect_err("no peer answers, so this must time out"); + assert!(matches!(err, ReconnectError::Timeout("msg2 notify")), "{err}"); + + let writes = fake.writes.lock().unwrap(); + assert_eq!(writes.len(), 1, "exactly msg1, nothing speculative"); + let (uuid, bytes, with_response) = &writes[0]; + assert_eq!(*uuid, RECONNECT_CONTROL_UUID.as_u128()); + assert!(!with_response, "§9.1: unacknowledged writes"); + + let frame = Frame::decode(bytes).expect("a well-formed frame"); + assert_eq!(frame.ty, ty::RECONNECT_HANDSHAKE); + assert_eq!(frame.sub, 0x01); + // Noise IK msg1 is e (32) + encrypted s (32+16) + encrypted payload + // (8-byte counter + 16 tag): a fixed 104 bytes for our pattern. A + // change here means the wire format moved. + assert_eq!(frame.payload.len(), 104); + } + + /// A frame that isn't msg2 must be rejected by type, not misparsed. The + /// peer is unauthenticated at this point, so this is the boundary where a + /// stray or hostile notification gets turned away. + #[tokio::test] + async fn a_wrong_frame_type_is_rejected_rather_than_decrypted() { + let fake = link(); + let uuid = RECONNECT_CONTROL_UUID.as_u128(); + let driver = async { + // Give the initiator a moment to subscribe and send msg1. + tokio::time::sleep(Duration::from_millis(5)).await; + fake.push_notification(uuid, Frame::new(ty::PAIRING_HANDSHAKE, 0x02, vec![0; 48]).encode()); + }; + let run = run_ik_initiator( + &fake, + &[7u8; 32], + &[9u8; 32], + &[3u8; 32], + 0, + Duration::from_millis(200), + ); + let (outcome, ()) = tokio::join!(run, driver); + match outcome.expect_err("must not accept a foreign frame") { + ReconnectError::UnexpectedFrame { ty, sub } => { + assert_eq!((ty, sub), (crate::core::ble::frame::ty::PAIRING_HANDSHAKE, 0x02)); + } + other => panic!("expected UnexpectedFrame, got {other}"), + } + } + + /// A link that can't carry the write fails the handshake with the reason, + /// rather than hanging until the step timeout. + #[tokio::test] + async fn a_dead_link_fails_fast_with_its_own_error() { + // Nothing present → subscribe itself fails. + let fake = FakeGattLink::new(vec![]); + let err = run_ik_initiator( + &fake, + &[7u8; 32], + &[9u8; 32], + &[3u8; 32], + 0, + Duration::from_secs(30), + ) + .await + .expect_err("a link with no characteristic cannot handshake"); + assert!(matches!(err, ReconnectError::Link(_)), "{err}"); + } +} diff --git a/linux/daemon/src/core/platform/linux.rs b/linux/daemon/src/core/platform/linux.rs new file mode 100644 index 0000000..8c7e5fa --- /dev/null +++ b/linux/daemon/src/core/platform/linux.rs @@ -0,0 +1,547 @@ +//! Linux implementations of the platform seam. +//! +//! These delegate to the modules that already existed — the seam is a boundary, +//! not a rewrite, so behaviour on Linux is unchanged by construction. + +use std::path::{Path, PathBuf}; + +use super::{BoxFuture, Notifier, SessionControl, UserPaths}; + +pub struct LinuxPaths; + +impl UserPaths for LinuxPaths { + /// The real XDG download directory (`~/Téléchargements` on a French + /// desktop), never a hardcoded English `~/Downloads` — that mistake + /// silently created a second folder beside the real one and filed every + /// received file where the user never looks. + fn downloads(&self) -> Option { + let home = PathBuf::from(std::env::var_os("HOME")?); + Some(xdg_download_dir(&home).unwrap_or_else(|| home.join("Downloads"))) + } + + fn config(&self) -> Option { + Some(config_home()?.join("vortex")) + } + + fn cache(&self) -> Option { + let home = PathBuf::from(std::env::var_os("HOME")?); + let base = std::env::var_os("XDG_CACHE_HOME") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .unwrap_or_else(|| home.join(".cache")); + Some(base.join("vortex")) + } + + /// The journal, in practice — this answers for completeness, and points at + /// the cache root so a file written here is never mistaken for state. + fn logs(&self) -> Option { + self.cache() + } +} + +fn config_home() -> Option { + let home = PathBuf::from(std::env::var_os("HOME")?); + Some( + std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .unwrap_or_else(|| home.join(".config")), + ) +} + +/// `XDG_DOWNLOAD_DIR` from the environment, else from the `user-dirs.dirs` file +/// `xdg-user-dir(1)` reads. Not required to exist — a configured-but-missing +/// folder is still the user's stated intent, and the caller creates it. +fn xdg_download_dir(home: &Path) -> Option { + if let Some(v) = std::env::var_os("XDG_DOWNLOAD_DIR") { + if let Some(p) = expand_home(&v.to_string_lossy(), home) { + return Some(p); + } + } + let text = std::fs::read_to_string(config_home()?.join("user-dirs.dirs")).ok()?; + expand_home(&parse_user_dirs(&text, "XDG_DOWNLOAD_DIR")?, home) +} + +/// Pull one key out of a `user-dirs.dirs` file: shell syntax, `# comment` lines +/// and `KEY="value"` assignments, last assignment winning as a shell would. +fn parse_user_dirs(text: &str, key: &str) -> Option { + let mut found = None; + for line in text.lines() { + let line = line.trim(); + if line.starts_with('#') { + continue; + } + let Some((k, v)) = line.split_once('=') else { + continue; + }; + if k.trim() != key { + continue; + } + let v = v.trim(); + let v = v + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .or_else(|| v.strip_prefix('\'').and_then(|s| s.strip_suffix('\''))) + .unwrap_or(v); + if !v.is_empty() { + found = Some(v.to_string()); + } + } + found +} + +/// Expand the `$HOME/…` (or `~/…`) prefix the spec mandates. Anything else must +/// already be absolute — a bare relative path is malformed, and guessing could +/// scatter files into the process's cwd. +fn expand_home(raw: &str, home: &Path) -> Option { + let raw = raw.trim(); + for prefix in ["$HOME", "${HOME}", "~"] { + if let Some(rest) = raw.strip_prefix(prefix) { + let rest = rest.trim_start_matches('/'); + return Some(if rest.is_empty() { + home.to_path_buf() + } else { + home.join(rest) + }); + } + } + let p = PathBuf::from(raw); + p.is_absolute().then_some(p) +} + +pub struct LinuxNotifier; + +impl Notifier for LinuxNotifier { + fn show( + &self, + summary: &str, + body: &str, + app_id: &str, + actions: &[(String, String)], + replaces: u32, + urgent: bool, + ) -> BoxFuture> { + let (summary, body, app_id) = (summary.to_string(), body.to_string(), app_id.to_string()); + let actions = actions.to_vec(); + Box::pin(async move { + crate::core::notification_display::show_call_banner( + &summary, &body, &app_id, &actions, replaces, urgent, + ) + .await + }) + } + + fn close(&self, id: u32) -> BoxFuture> { + Box::pin(async move { crate::core::notification_display::close(id).await }) + } + + fn actions(&self, tx: tokio::sync::mpsc::UnboundedSender<(u32, String)>) { + tokio::spawn(crate::core::notification_display::watch_actions(tx)); + } + + fn closures(&self, tx: tokio::sync::mpsc::UnboundedSender<(u32, u32)>) { + tokio::spawn(crate::core::notification_display::watch_closed(tx)); + } +} + +pub struct LinuxSession; + +impl SessionControl for LinuxSession { + fn lock(&self) -> BoxFuture> { + Box::pin(crate::core::session_lock::lock()) + } + + fn unlock(&self) -> BoxFuture> { + Box::pin(crate::core::session_lock::unlock()) + } + + fn is_locked(&self) -> BoxFuture> { + Box::pin(crate::core::session_lock::locked_hint()) + } + + /// logind can unlock, given the one-time polkit rule. + fn can_unlock(&self) -> bool { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A real French `user-dirs.dirs` — the case that made this code necessary. + const FR: &str = r#"# This file is written by xdg-user-dirs-update +XDG_DESKTOP_DIR="$HOME/Bureau" +XDG_DOWNLOAD_DIR="$HOME/Téléchargements" +XDG_DOCUMENTS_DIR="$HOME/Documents" +"#; + + #[test] + fn parses_localised_download_dir() { + let raw = parse_user_dirs(FR, "XDG_DOWNLOAD_DIR").expect("download dir"); + assert_eq!(raw, "$HOME/Téléchargements"); + assert_eq!( + expand_home(&raw, Path::new("/home/cyril")), + Some(PathBuf::from("/home/cyril/Téléchargements")) + ); + } + + #[test] + fn ignores_comments_and_other_keys() { + assert_eq!(parse_user_dirs(FR, "XDG_MUSIC_DIR"), None); + let text = "#XDG_DOWNLOAD_DIR=\"$HOME/nope\"\nXDG_DOWNLOAD_DIR=\"$HOME/yes\"\n"; + assert_eq!( + parse_user_dirs(text, "XDG_DOWNLOAD_DIR"), + Some("$HOME/yes".to_string()) + ); + } + + #[test] + fn last_assignment_wins_like_a_shell() { + let text = "XDG_DOWNLOAD_DIR=\"$HOME/first\"\nXDG_DOWNLOAD_DIR=\"$HOME/second\"\n"; + assert_eq!( + parse_user_dirs(text, "XDG_DOWNLOAD_DIR"), + Some("$HOME/second".to_string()) + ); + } + + #[test] + fn expands_home_forms_and_rejects_relative() { + let home = Path::new("/home/cyril"); + for raw in ["$HOME/Dl", "${HOME}/Dl", "~/Dl"] { + assert_eq!(expand_home(raw, home), Some(PathBuf::from("/home/cyril/Dl"))); + } + assert_eq!(expand_home("$HOME/", home), Some(home.to_path_buf())); + assert_eq!(expand_home("/data/dl", home), Some(PathBuf::from("/data/dl"))); + assert_eq!(expand_home("Downloads", home), None); + assert_eq!(expand_home("", home), None); + } + + #[test] + fn handles_unquoted_and_single_quoted() { + assert_eq!( + parse_user_dirs("XDG_DOWNLOAD_DIR=$HOME/Dl\n", "XDG_DOWNLOAD_DIR"), + Some("$HOME/Dl".to_string()) + ); + assert_eq!( + parse_user_dirs("XDG_DOWNLOAD_DIR='$HOME/Dl'\n", "XDG_DOWNLOAD_DIR"), + Some("$HOME/Dl".to_string()) + ); + } +} + + +// --------------------------------------------------------------------------- +// BLE central over BlueZ +// --------------------------------------------------------------------------- + +use std::sync::Arc; + +use super::{AdvCandidate, AudioHandoff, BleCentral, GattLink, PeerAddr, Uuid128}; +use crate::core::ble::client::VortexClient; +use crate::core::ble::{ + AUDIO_SIGNAL_UUID, CAPABILITY_UUID, PAIRING_CONTROL_UUID, RECONNECT_CONTROL_UUID, +}; + +/// BlueZ-backed [`BleCentral`]. Wraps the existing [`VortexClient`] and scanner +/// rather than reimplementing them: everything hard-won about connecting to a +/// dual-mode phone (see the bearer-selection comment in `ble::client`) stays in +/// one place, and this file only adapts the shapes. +pub struct LinuxBleCentral { + adapter: bluer::Adapter, +} + +impl LinuxBleCentral { + /// Takes the process's shared adapter — see the note on [`BleCentral`] for + /// why this is passed in rather than acquired here. + pub fn new(adapter: bluer::Adapter) -> Self { + Self { adapter } + } +} + +impl BleCentral for LinuxBleCentral { + /// First Vortex advertisement seen, or `None` on timeout. + /// + /// `run_filtered_scan` never returns on its own — it is meant to be driven + /// until dropped — so it races against the deadline and the first hit. + fn scan_for_peer(&self, timeout_ms: u64) -> BoxFuture, String>> { + let adapter = self.adapter.clone(); + Box::pin(async move { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + let scan = crate::core::ble::scanner::run_filtered_scan(adapter, move |c| { + // The scanner has already run the §5.2 filter, so the payload + // it hands over is valid — pass it through rather than + // re-deriving anything from the address. + let _ = tx.send(AdvCandidate { + addr: PeerAddr(c.address.0), + payload: c.payload, + rssi: c.rssi, + local_name: c.local_name.clone(), + }); + }); + tokio::select! { + // Dropping `scan` here stops the discovery, which is the + // documented way to end it. + Some(found) = rx.recv() => Ok(Some(found)), + r = scan => match r { + // It only returns on error; a clean return means the + // discovery ended without a candidate. + Ok(()) => Ok(None), + Err(e) => Err(format!("scan: {e}")), + }, + _ = tokio::time::sleep(std::time::Duration::from_millis(timeout_ms)) => Ok(None), + } + }) + } + + fn connect(&self, addr: PeerAddr) -> BoxFuture, String>> { + let adapter = self.adapter.clone(); + Box::pin(async move { + let address = bluer::Address::new(addr.0); + let client = VortexClient::connect(&adapter, address) + .await + .map_err(|e| format!("connect {address}: {e}"))?; + Ok(Box::new(LinuxGattLink::from_client(adapter, &client)) as Box) + }) + } + + /// Paired devices known to the adapter. + /// + /// A device that fails to answer `is_paired` is skipped rather than + /// failing the list: one stale record must not hide the rest. + fn bonded(&self) -> BoxFuture, String>> { + let adapter = self.adapter.clone(); + Box::pin(async move { + let addrs = adapter + .device_addresses() + .await + .map_err(|e| format!("device_addresses: {e}"))?; + let mut out = Vec::new(); + for a in addrs { + let Ok(dev) = adapter.device(a) else { continue }; + if dev.is_paired().await.unwrap_or(false) { + out.push(PeerAddr(a.0)); + } + } + Ok(out) + }) + } + + fn adapter_ready(&self) -> BoxFuture { + let adapter = self.adapter.clone(); + Box::pin(async move { adapter.is_powered().await.unwrap_or(false) }) + } +} + +/// An open BlueZ GATT link: the characteristics a [`VortexClient`] resolved, +/// plus the adapter and address needed to answer "are we still connected?". +/// +/// Holds the characteristics rather than the client so that [`Self::from_client`] +/// can BORROW a client the caller keeps using. bluer's handles are cheap clones +/// of D-Bus paths, and the existing call sites (pairing, the BLE loop) still +/// want their `VortexClient` for the typed helpers after the handshake. +pub struct LinuxGattLink { + adapter: bluer::Adapter, + address: bluer::Address, + capability: bluer::gatt::remote::Characteristic, + pairing_control: bluer::gatt::remote::Characteristic, + reconnect_control: bluer::gatt::remote::Characteristic, + /// `None` on phone builds before P2.13 — see [`GattLink::has`]. + audio_signal: Option, + /// One forwarding task per subscription, aborted on disconnect. bluer hands + /// back a Stream; the seam hands out a channel, so something has to pump. + /// + /// `Arc` because the returned futures are `'static` (the trait's `BoxFuture` + /// carries no lifetime), so they cannot borrow from `&self` — they get a + /// handle instead. + notify_tasks: Arc>>>, +} + +impl LinuxGattLink { + /// Present an already-connected [`VortexClient`] as a [`GattLink`]. + /// + /// This is the migration path: the pairing and reconnect flows move onto + /// `&dyn GattLink` while their callers keep the connect logic they have — + /// all the dual-mode bearer handling in `ble::client` — and simply wrap it + /// here. No second connect, no behaviour change. + pub fn from_client(adapter: bluer::Adapter, client: &VortexClient) -> Self { + Self { + adapter, + address: client.address, + capability: client.capability.clone(), + pairing_control: client.pairing_control.clone(), + reconnect_control: client.reconnect_control.clone(), + audio_signal: client.audio_signal.clone(), + notify_tasks: Arc::new(std::sync::Mutex::new(Vec::new())), + } + } +} + +impl LinuxGattLink { + /// Resolve a UUID to the characteristic the client already discovered. + /// + /// A match rather than a map because the set is fixed by the spec (§10.1) + /// and `audio_signal` is optional — an absent one has to read as "not on + /// this peer", not as a lookup bug. + fn characteristic( + &self, + uuid: Uuid128, + ) -> Result<&bluer::gatt::remote::Characteristic, String> { + let u = uuid::Uuid::from_u128(uuid); + if u == CAPABILITY_UUID { + Ok(&self.capability) + } else if u == PAIRING_CONTROL_UUID { + Ok(&self.pairing_control) + } else if u == RECONNECT_CONTROL_UUID { + Ok(&self.reconnect_control) + } else if u == AUDIO_SIGNAL_UUID { + self.audio_signal + .as_ref() + .ok_or_else(|| "audio-signal characteristic absent on this peer".to_string()) + } else { + Err(format!("{u} is not a vortex characteristic")) + } + } +} + +impl GattLink for LinuxGattLink { + fn write( + &self, + char_uuid: Uuid128, + data: &[u8], + with_response: bool, + ) -> BoxFuture> { + let c = self.characteristic(char_uuid).cloned(); + let bytes = data.to_vec(); + Box::pin(async move { + let c = c?; + let req = bluer::gatt::remote::CharacteristicWriteRequest { + offset: 0, + op_type: if with_response { + bluer::gatt::WriteOp::Request + } else { + bluer::gatt::WriteOp::Command + }, + prepare_authorize: false, + ..Default::default() + }; + c.write_ext(&bytes, &req) + .await + .map_err(|e| format!("gatt write: {e}")) + }) + } + + fn read(&self, char_uuid: Uuid128) -> BoxFuture, String>> { + let c = self.characteristic(char_uuid).cloned(); + Box::pin(async move { c?.read().await.map_err(|e| format!("gatt read: {e}")) }) + } + + fn subscribe( + &self, + char_uuid: Uuid128, + tx: tokio::sync::mpsc::UnboundedSender>, + ) -> BoxFuture> { + let c = self.characteristic(char_uuid).cloned(); + let tasks = Arc::clone(&self.notify_tasks); + Box::pin(async move { + let c = c?; + let stream = c.notify().await.map_err(|e| format!("gatt notify: {e}"))?; + let handle = tokio::spawn(async move { + use futures::StreamExt; + let mut stream = std::pin::pin!(stream); + while let Some(bytes) = stream.next().await { + if tx.send(bytes).is_err() { + break; // consumer gone + } + } + }); + if let Ok(mut g) = tasks.lock() { + g.push(handle); + } + Ok(()) + }) + } + + fn peer(&self) -> PeerAddr { + PeerAddr(self.address.0) + } + + fn has(&self, char_uuid: Uuid128) -> bool { + self.characteristic(char_uuid).is_ok() + } + + /// Stop forwarding notifications and let the link go. + /// + /// Deliberately does NOT call `Device::disconnect()`. On a dual-mode phone + /// that tears down every bearer, including the A2DP/HFP link if the phone + /// is also paired as an audio device — so a "close this GATT link" would + /// cut the user's music. BlueZ drops the LE link once the handles go, which + /// is what the pre-seam code relied on too. + fn disconnect(&self) -> BoxFuture> { + let taken: Vec> = self + .notify_tasks + .lock() + .map(|mut g| std::mem::take(&mut *g)) + .unwrap_or_default(); + Box::pin(async move { + for t in taken { + t.abort(); + } + Ok(()) + }) + } + + fn is_connected(&self) -> BoxFuture { + let adapter = self.adapter.clone(); + let address = self.address; + Box::pin(async move { + match adapter.device(address) { + Ok(d) => d.is_connected().await.unwrap_or(false), + Err(_) => false, + } + }) + } +} + +/// Linux audio handoff: the PulseAudio/BlueZ switch orchestrator plus the MPRIS +/// store the fast-path pause needs. Both already existed; this only presents +/// them to the (platform-neutral) BLE event stream. +pub struct LinuxAudioHandoff { + orchestrator: Arc, + media_store: crate::core::media_runtime::MediaStateStore, +} + +impl LinuxAudioHandoff { + pub fn new( + orchestrator: Arc, + media_store: crate::core::media_runtime::MediaStateStore, + ) -> Self { + Self { + orchestrator, + media_store, + } + } +} + +impl AudioHandoff for LinuxAudioHandoff { + fn pause_for_call(&self) -> BoxFuture<()> { + let store = self.media_store.clone(); + Box::pin(async move { + let paused = crate::core::media_runtime::pause_playing_for_call(&store).await; + if !paused.is_empty() { + tracing::info!(?paused, "BLE fast-path: paused MPRIS for call"); + } + }) + } + + fn on_incoming( + &self, + peer: [u8; 32], + frame: crate::core::audio_op::AudioOpFrame, + ) -> BoxFuture<()> { + let orch = Arc::clone(&self.orchestrator); + Box::pin(async move { + let _ = orch.on_incoming(peer, frame).await; + }) + } +} diff --git a/linux/daemon/src/core/platform/mod.rs b/linux/daemon/src/core/platform/mod.rs new file mode 100644 index 0000000..c14d499 --- /dev/null +++ b/linux/daemon/src/core/platform/mod.rs @@ -0,0 +1,682 @@ +//! The platform seam: everything the laptop side needs from the OS, expressed +//! as traits so a second OS can be added without touching feature logic. +//! +//! # Why this exists +//! +//! Until now every OS call was made inline against a Linux API — BlueZ over +//! D-Bus, logind, XDG directories, the freedesktop notification service — with +//! no `cfg(target_os)` anywhere in the tree. That is fine for one OS and +//! impossible for two. These traits are the boundary: **feature logic above, +//! OS below**. The rule is that nothing above this line names a Linux concept. +//! +//! # What is deliberately NOT here +//! +//! * **Storage.** [`crate::core::storage`] already has `IdentityStore` and +//! `PeerStore`; a Windows Credential Manager implementation slots in beside +//! `SecretServiceIdentityStore` with no new trait. +//! * **The wire protocol, crypto, framing, LAN and mDNS.** They are pure Rust +//! and must stay byte-identical across platforms — the phone cannot tell the +//! two laptops apart, and `shared/vectors/` exists to keep it that way. +//! * **Clipboard.** `arboard` already covers Linux and Windows. +//! +//! # Status +//! +//! Linux implementations delegate to the modules that already existed, so this +//! file adds a boundary without changing behaviour there. **Every trait here now +//! has a Windows implementation**, and so does the secret store — Credential +//! Manager in `storage::windows_credentials`, beside the Secret Service one. +//! +//! What no Windows build has yet is the layer ABOVE this: the Tauri app is +//! Linux-only (its tray, its D-Bus consumers, its uinput injector), and the +//! `vortex-l3d` CLI is a Linux BLE harness. This seam is what makes that layer +//! portable, not a substitute for porting it. +//! +//! Everything Windows-side is compiled only on Windows and none of it has ever +//! run: it type-checks against the WinRT/Win32 metadata, which catches wrong +//! signatures and wrong types and nothing about behaviour. +//! +//! **The daemon LIBRARY compiles for Windows.** Verify with: +//! +//! ```text +//! cargo check -p vortex-l3-daemon --lib --target x86_64-pc-windows-gnu +//! ``` +//! +//! Two notes on that command. `--lib`, because `src/main.rs` is a Linux BLE CLI +//! harness and is not part of a Windows build (the product there is the Tauri +//! app). And `-gnu` rather than `-msvc`: an MSVC cross-check needs `lib.exe`, +//! which a Linux box does not have, so it dies in `cc-rs` before reaching our +//! code. The GNU target type-checks the same source. +//! +//! What compiles is the platform-neutral core: crypto, framing, the wire +//! protocol (`ble::frame`), LAN + mDNS, the pairing state machine, appstate, +//! the storage traits, and this seam. What is gated out — with the reason on +//! each `cfg` — is every direct BlueZ / D-Bus / PulseAudio / Secret Service +//! module. +//! +//! # BLE is the one trait with both sides written +//! +//! [`BleCentral`] / [`GattLink`] now have a BlueZ implementation +//! ([`linux::LinuxBleCentral`], wrapping the existing `ble::client` rather than +//! reimplementing its dual-mode connect dance) and a WinRT one +//! ([`windows::ble::WindowsBleCentral`]). Writing the second one is what +//! reshaped the trait: it needed a `read` (the capability handshake), a `has` +//! (the audio-signal characteristic is absent on older phones), an async +//! `is_connected` (BlueZ answers over D-Bus) and a `scan_for_peer` that returns +//! the advertisement PAYLOAD rather than a bare address — the phone rotates its +//! address, so the payload is what identifies a peer. +//! +//! Its callers moved over too: `pairing::{handshake, reconnect}` now take +//! `&dyn GattLink`, are no longer gated, and build for Windows. That also made +//! them testable for the first time — a full XX pairing with dual approval, and +//! the IK msg1 wire shape, now run as unit tests against [`FakeGattLink`] with +//! no adapter and no phone. +//! +//! `ble::audio_signal` — the post-handshake event stream, all nineteen frame +//! types plus the nonce-resync recovery — moved across too. Its one genuinely +//! local dependency, the earbuds handoff, went behind [`AudioHandoff`]; a +//! platform with no audio backend passes `None`, drops `AUDIO_OP`, and keeps +//! the other eighteen. +//! +//! # The gates are not the port +//! +//! A `cfg(target_os = "linux")` on a module means "no Windows implementation +//! yet", not "not needed on Windows". What is left behind one is a Linux +//! *implementation* — BlueZ, logind, MPRIS, PulseAudio, Secret Service — with +//! its trait already named here, or a subsystem with no Windows counterpart +//! written yet. + +use std::path::PathBuf; + +#[cfg(target_os = "linux")] +pub mod linux; +pub mod toast_xml; +pub mod vk_to_evdev; +#[cfg(target_os = "windows")] +pub mod windows; + +/// Standard user directories. Localised on both platforms and NOT derivable by +/// joining an English folder name onto `$HOME` — the French desktop this was +/// first written on uses `~/Téléchargements`, and Windows relocates the +/// Downloads folder freely (OneDrive moves it by default). +pub trait UserPaths: Send + Sync { + /// Where received files are saved. Must be the user's real download folder. + fn downloads(&self) -> Option; + /// Per-user config root (`~/.config/vortex`, `%APPDATA%\Vortex`). + fn config(&self) -> Option; + /// Per-user cache root — icon cache, transient blobs. + fn cache(&self) -> Option; + /// Where a log file goes, on a platform that writes one. + /// + /// Linux does not: the app runs under a systemd user unit and its output is + /// the journal, which is where every diagnosis in this project has come + /// from. Windows has no such thing for a desktop app, so it writes a file — + /// and "where is the log" must not be a guess when the first run of + /// never-executed code goes wrong. + fn logs(&self) -> Option; +} + +/// This machine's name, as the user would recognise it. +/// +/// Lives at the seam because there is no portable way to ask: Linux reads +/// `/proc`, Windows has an environment variable that Linux does not set. Both +/// the pairing APPROVE frame and every AppState heartbeat carry this, and they +/// have to agree — a heartbeat that reports `None` overwrites the name the +/// phone learned at pairing time with a blank, which is what made a +/// freshly-paired Windows laptop show up on the phone as "null". +pub fn host_name() -> Option { + #[cfg(target_os = "linux")] + let raw = std::fs::read_to_string("/proc/sys/kernel/hostname").ok(); + // `COMPUTERNAME` is set for every interactive session, and the NetBIOS name + // it holds is what the machine calls itself in every Windows UI. + #[cfg(not(target_os = "linux"))] + let raw = std::env::var("COMPUTERNAME").ok(); + raw.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) +} + +/// A desktop notification carrying optional action buttons. +/// +/// The hard part on both platforms is not showing it, it is getting the click +/// back. On Linux the sender must stay on the bus for Plasma to keep the +/// buttons, while GNOME needs a windowless sender. On Windows the toast needs +/// an AppUserModelID, and an unpackaged app needs a registered COM activator +/// before an action can round-trip at all. +pub trait Notifier: Send + Sync { + /// Show (or replace, when `replaces` is non-zero) a notification. `actions` + /// is `(key, label)`; the key comes back through [`Notifier::actions`]. + fn show( + &self, + summary: &str, + body: &str, + app_id: &str, + actions: &[(String, String)], + replaces: u32, + urgent: bool, + ) -> BoxFuture>; + + /// Withdraw a notification we previously showed. + fn close(&self, id: u32) -> BoxFuture>; + + /// Stream of `(notification id, action key)` for every button the user + /// clicks. One process-wide stream: consumers filter by key prefix + /// (`fc:` file consent, `call:` call banner, `act:` mirrored action). + fn actions(&self, tx: tokio::sync::mpsc::UnboundedSender<(u32, String)>); + + /// Stream of `(notification id, reason)` closures, so a dismissal on the + /// laptop can be mirrored back to the phone. + fn closures(&self, tx: tokio::sync::mpsc::UnboundedSender<(u32, u32)>); +} + +/// Lock / unlock the desktop session and report its current state — the +/// proximity feature's entire OS surface. +/// +/// Windows can lock (`LockWorkStation`) but deliberately cannot unlock +/// programmatically, so proximity *auto-unlock* is Linux-only and the trait +/// lets an implementation say so rather than fail at the call site. +pub trait SessionControl: Send + Sync { + fn lock(&self) -> BoxFuture>; + fn unlock(&self) -> BoxFuture>; + /// `None` when the platform can't report it. + fn is_locked(&self) -> BoxFuture>; + /// Whether [`SessionControl::unlock`] can work at all here. + fn can_unlock(&self) -> bool; +} + +/// The audio-handoff side of the phone's event stream. +/// +/// `ble::audio_signal` carries nineteen frame types, and exactly one of them — +/// `AUDIO_OP`, the earbuds handoff — needs to touch the local audio stack. This +/// trait is that touch point, so the other eighteen don't drag PulseAudio and +/// MPRIS into a build that has neither. +/// +/// A platform with no audio backend passes `None` and simply drops `AUDIO_OP` +/// frames: no earbuds switching, everything else works. +pub trait AudioHandoff: Send + Sync { + /// The phone is starting a buds-claim (almost always an incoming call). + /// Pause local media BEFORE the buds are released — once the sink goes away + /// the audio server migrates the stream and the player often auto-pauses on + /// its own, leaving nothing to resume later. + fn pause_for_call(&self) -> BoxFuture<()>; + + /// Drive the switch state machine with a frame from `peer`. + fn on_incoming(&self, peer: [u8; 32], frame: crate::core::audio_op::AudioOpFrame) + -> BoxFuture<()>; +} + +/// Run Vortex at login. +pub trait Autostart: Send + Sync { + fn is_enabled(&self) -> bool; + fn set_enabled(&self, on: bool) -> Result<(), String>; +} + +/// Quote an executable path for a Windows command line. +/// +/// The registry `Run` value is a COMMAND LINE, not a path: Windows splits it on +/// whitespace, so `C:\Program Files\Vortex\vortex.exe` unquoted launches +/// `C:\Program`. Since the default install location contains a space, an +/// unquoted value fails on essentially every machine — silently, at the next +/// logon, where nobody is watching. +/// +/// Lives here with a test rather than inline in the Windows module, for the same +/// reason as [`toast_xml`]: it is pure string work whose failure mode is +/// invisible. +pub fn quoted_command(exe: &std::path::Path) -> String { + let s = exe.to_string_lossy(); + if s.starts_with('"') && s.ends_with('"') && s.len() > 1 { + // Already quoted — double-quoting would make the path literal-quotes. + return s.to_string(); + } + format!("\"{s}\"") +} + +/// Pointer/keyboard capture for Universal Control: hold the cursor at a screen +/// edge, take exclusive input, and stream events for forwarding to the phone. +/// +/// This is the one subsystem that is *easier* on Windows — a low-level hook +/// plus `ClipCursor` does what Wayland needs the input-capture portal and libei +/// for, and it works the same on every Windows desktop. +pub trait InputCapture: Send + Sync { + /// Arm capture on the given edge. Events flow to `tx` until released. + fn arm(&self, edge: Edge, tx: tokio::sync::mpsc::UnboundedSender) + -> BoxFuture>; + /// Release capture; the cursor returns to the laptop. + fn release(&self) -> BoxFuture>; + /// Hide the laptop's own cursor while control is on the phone. Best-effort: + /// GNOME-only today, and `false` means "couldn't", not "failed". + fn hide_cursor(&self, hidden: bool) -> bool; +} + +/// Which screen edge the phone sits on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Edge { + Left, + Right, + Top, + Bottom, +} + +/// One captured input event, already in the platform-neutral form the phone +/// side expects. +#[derive(Debug, Clone, Copy)] +pub enum InputEvent { + /// Relative pointer motion. + Motion { dx: f64, dy: f64 }, + /// Button 1 = left, 2 = middle, 3 = right. + Button { button: u8, pressed: bool }, + /// Vertical / horizontal scroll, in notches. + Scroll { dx: f64, dy: f64 }, + /// A Linux evdev keycode — the phone side already speaks these, so Windows + /// translates its VK codes into this space rather than inventing a third. + Key { keycode: u16, pressed: bool }, +} + +/// BLE central role: scan for the phone's advertisement, connect, and talk GATT. +/// +/// The laptop is central-**only** — it never advertises and never serves a GATT +/// server, which is what makes Windows viable at all (WinRT's peripheral role is +/// far weaker than its central role). +/// +/// # Construction is deliberately not part of this trait +/// +/// There is no `platform::ble()` factory to match [`paths`] / [`notifier`] / +/// [`session`], because the two platforms genuinely differ in what they need to +/// exist: +/// +/// * Linux takes the process's ONE shared `bluer::Adapter` +/// ([`linux::LinuxBleCentral::new`]). Creating a session per use accumulated +/// D-Bus connections and hung the app after a few call cycles, so the adapter +/// is passed in rather than acquired — the same reason the heartbeat and the +/// BLE loop already share one. +/// * Windows needs no handle at all; WinRT resolves the radio per call. +/// +/// A uniform factory would have to hide that, and hiding it is how the leak +/// came back. Callers construct the platform's central once at startup and pass +/// `Arc` down, which is what the BLE loop already does with its +/// adapter today. +pub trait BleCentral: Send + Sync { + /// Scan until a Vortex advertisement is seen, or the timeout elapses. + fn scan_for_peer(&self, timeout_ms: u64) -> BoxFuture, String>>; + /// Connect and resolve the Vortex GATT service. + fn connect(&self, addr: PeerAddr) -> BoxFuture, String>>; + /// Addresses of already-bonded devices, for the reconnect fast path. + fn bonded(&self) -> BoxFuture, String>>; + /// Whether the radio is present and powered. + fn adapter_ready(&self) -> BoxFuture; +} + +/// An open GATT connection to the phone. +/// +/// The shape of this trait is set by what the pairing and reconnect flows +/// actually do over the link, which is: read the capability characteristic, +/// write frames without response (§9.1 — the flow is driven by notify-on-write, +/// so the ATT ack buys latency and no reliability), and subscribe for the +/// notifications those writes provoke. +pub trait GattLink: Send + Sync { + /// Write one frame to a characteristic. `with_response = false` is an ATT + /// Write Command, which is what the pairing and reconnect frames use. + fn write(&self, char_uuid: Uuid128, data: &[u8], with_response: bool) + -> BoxFuture>; + + /// Read a characteristic — the capability handshake (§9.1.5) needs this + /// before any frame is written. + fn read(&self, char_uuid: Uuid128) -> BoxFuture, String>>; + + /// Subscribe to notifications; frames arrive on `tx` until disconnect. + fn subscribe(&self, char_uuid: Uuid128, tx: tokio::sync::mpsc::UnboundedSender>) + -> BoxFuture>; + + /// Which peer this link talks to. Both platforms know it at connect time; + /// it exists so log lines can name the device without the caller having to + /// carry the address alongside the link. + fn peer(&self) -> PeerAddr; + + /// Whether this link resolved `char_uuid` at all. + /// + /// Not every characteristic is guaranteed: the audio-signal one is absent + /// on phone builds before P2.13, and those peers must keep working with the + /// LAN heartbeat instead of failing the connect. Callers check rather than + /// discovering it as a write error. + fn has(&self, char_uuid: Uuid128) -> bool; + + fn disconnect(&self) -> BoxFuture>; + + /// Async because Linux has to ask BlueZ over D-Bus; Windows reads a + /// property. A sync signature would have forced Linux to cache a flag and + /// answer with something stale. + fn is_connected(&self) -> BoxFuture; +} + +/// A Vortex peer seen on air. +/// +/// Carries the advertisement payload, not just the address, because the address +/// alone cannot answer the questions the callers ask: the pairing UI needs the +/// `pairable` flag and the instance id to match the window the user just opened +/// on the phone, and the reconnect path needs the presence token to know WHICH +/// trusted peer this is. The phone rotates its address every few minutes, so it +/// is the payload that identifies, not the address. +// Not `Copy`: `local_name` is an owned `String`. Nothing needs it to be — the +// two fields callers pass around by value (`addr`, `rssi`) are `Copy` on their +// own. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AdvCandidate { + pub addr: PeerAddr, + pub payload: crate::core::ble::AdvPayload, + /// Signal strength, where the platform reports it — used only to prefer a + /// nearer peer, never to decide identity. + pub rssi: Option, + /// The advert's Complete Local Name, when it carries one. + /// + /// Cosmetic and untrusted: it is what the pairing radar labels a row with + /// so the user recognises their own phone instead of reading a rotating + /// random address. The name that ends up in the trust store is the one + /// inside the authenticated APPROVE frame, never this. + pub local_name: Option, +} + +/// A Bluetooth device address. Deliberately a plain newtype rather than +/// `bluer::Address`: Windows hands out a `u64`, and the resolvable private +/// addresses the phone rotates through mean the *value* is never a stable +/// identity anyway — the peer's static public key is. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct PeerAddr(pub [u8; 6]); + +impl PeerAddr { + /// From the `u64` WinRT hands out (`BluetoothLEDevice::BluetoothAddress`, + /// `BluetoothLEAdvertisementReceivedEventArgs::BluetoothAddress`). + /// + /// The 48-bit address occupies the low six bytes, most-significant byte + /// first in printed order: `0x0000_AABB_CCDD_EEFF` is `AA:BB:CC:DD:EE:FF`. + /// The top two bytes are always zero and are dropped. Kept here rather than + /// in the Windows module so it can be tested on either platform — it is + /// pure arithmetic, and getting it backwards would mean connecting to a + /// mirrored address that simply never answers. + pub fn from_u48(addr: u64) -> Self { + let b = addr.to_be_bytes(); + Self([b[2], b[3], b[4], b[5], b[6], b[7]]) + } + + /// The inverse of [`PeerAddr::from_u48`], for handing an address back to a + /// WinRT call. + pub fn to_u48(self) -> u64 { + let a = self.0; + u64::from_be_bytes([0, 0, a[0], a[1], a[2], a[3], a[4], a[5]]) + } +} + +impl std::fmt::Display for PeerAddr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let b = self.0; + write!( + f, + "{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}", + b[0], b[1], b[2], b[3], b[4], b[5] + ) + } +} + +/// A 128-bit GATT UUID, in the same byte order both platforms accept. +pub type Uuid128 = u128; + +/// Boxed future alias — these traits are object-safe on purpose (the active +/// platform is chosen at runtime through `dyn`, so feature code never carries a +/// platform type parameter). +pub type BoxFuture = std::pin::Pin + Send>>; + +/// The active platform's user paths. +pub fn paths() -> &'static dyn UserPaths { + #[cfg(target_os = "linux")] + { + &linux::LinuxPaths + } + #[cfg(target_os = "windows")] + { + &windows::WindowsPaths + } +} + +/// The active platform's notifier. +pub fn notifier() -> &'static dyn Notifier { + #[cfg(target_os = "linux")] + { + &linux::LinuxNotifier + } + #[cfg(target_os = "windows")] + { + &windows::notify::WindowsNotifier + } +} + +/// The active platform's session control. +pub fn session() -> &'static dyn SessionControl { + #[cfg(target_os = "linux")] + { + &linux::LinuxSession + } + #[cfg(target_os = "windows")] + { + &windows::WindowsSession + } +} + +/// A [`GattLink`] with no radio behind it: writes are recorded, reads replay a +/// scripted value, and notifications are whatever the test pushes. +/// +/// This is the payoff for having a seam at all. The pairing and reconnect flows +/// are the code most worth testing and the least testable — today they need a +/// phone, a BlueZ adapter and a human. Written against `&dyn GattLink` they can +/// be driven from a unit test on either platform, and this is the harness that +/// makes that possible. It lives here rather than in a test module so the port +/// work can use it as it moves those flows onto the trait. +#[cfg(test)] +pub struct FakeGattLink { + /// Characteristics this link pretends to have. + pub present: Vec, + /// `(uuid, bytes, with_response)` in call order. + pub writes: std::sync::Mutex, bool)>>, + /// What [`GattLink::read`] answers, per characteristic. + pub reads: std::collections::HashMap>, + /// Senders handed to [`GattLink::subscribe`], so a test can push frames. + pub subscribers: std::sync::Mutex>)>>, + pub connected: bool, +} + +#[cfg(test)] +impl FakeGattLink { + pub fn new(present: Vec) -> Self { + Self { + present, + writes: std::sync::Mutex::new(Vec::new()), + reads: std::collections::HashMap::new(), + subscribers: std::sync::Mutex::new(Vec::new()), + connected: true, + } + } + + /// Deliver `bytes` as a notification on `uuid`, as the phone would. + pub fn push_notification(&self, uuid: Uuid128, bytes: Vec) { + for (u, tx) in self.subscribers.lock().unwrap().iter() { + if *u == uuid { + let _ = tx.send(bytes.clone()); + } + } + } +} + +#[cfg(test)] +impl GattLink for FakeGattLink { + fn write( + &self, + char_uuid: Uuid128, + data: &[u8], + with_response: bool, + ) -> BoxFuture> { + let ok = self.present.contains(&char_uuid); + if ok { + self.writes + .lock() + .unwrap() + .push((char_uuid, data.to_vec(), with_response)); + } + Box::pin(async move { + if ok { + Ok(()) + } else { + Err("no such characteristic".to_string()) + } + }) + } + + fn read(&self, char_uuid: Uuid128) -> BoxFuture, String>> { + let v = self.reads.get(&char_uuid).cloned(); + Box::pin(async move { v.ok_or_else(|| "nothing scripted for this read".to_string()) }) + } + + fn subscribe( + &self, + char_uuid: Uuid128, + tx: tokio::sync::mpsc::UnboundedSender>, + ) -> BoxFuture> { + let ok = self.present.contains(&char_uuid); + if ok { + self.subscribers.lock().unwrap().push((char_uuid, tx)); + } + Box::pin(async move { + if ok { + Ok(()) + } else { + Err("no such characteristic".to_string()) + } + }) + } + + fn peer(&self) -> PeerAddr { + PeerAddr([0xFA, 0xCE, 0x00, 0x00, 0x00, 0x01]) + } + + fn has(&self, char_uuid: Uuid128) -> bool { + self.present.contains(&char_uuid) + } + + fn disconnect(&self) -> BoxFuture> { + Box::pin(async { Ok(()) }) + } + + fn is_connected(&self) -> BoxFuture { + let c = self.connected; + Box::pin(async move { c }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The byte order is a wire-level detail with no error path: a mirrored + /// address is a valid-looking address that nothing answers on. + #[test] + fn u48_round_trips_and_keeps_printed_order() { + let a = PeerAddr([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]); + assert_eq!(a.to_u48(), 0x0000_AABB_CCDD_EEFF); + assert_eq!(PeerAddr::from_u48(0x0000_AABB_CCDD_EEFF), a); + assert_eq!(a.to_string(), "AA:BB:CC:DD:EE:FF"); + } + + #[test] + fn the_high_two_bytes_are_dropped() { + // WinRT always zeroes them; be explicit that we don't fold them in. + assert_eq!( + PeerAddr::from_u48(0xFFFF_0011_2233_4455), + PeerAddr([0x00, 0x11, 0x22, 0x33, 0x44, 0x55]), + ); + } + + #[test] + fn a_random_address_survives_a_round_trip() { + for seed in [0u64, 1, 0x0000_0102_0304_0506, 0x0000_FFFF_FFFF_FFFF] { + assert_eq!(PeerAddr::from_u48(seed).to_u48(), seed); + } + } + + #[test] + fn a_command_line_quotes_a_path_with_spaces() { + // The case that matters: the default install location has a space, and + // an unquoted value launches "C:\Program". + assert_eq!( + quoted_command(std::path::Path::new("C:\\Program Files\\Vortex\\vortex.exe")), + "\"C:\\Program Files\\Vortex\\vortex.exe\"" + ); + } + + #[test] + fn quoting_is_idempotent_and_unconditional() { + // Always quoted, even without a space — a conditional rule is one more + // thing to get wrong, and quotes are harmless here. + assert_eq!(quoted_command(std::path::Path::new("C:\\v.exe")), "\"C:\\v.exe\""); + // An already-quoted path must not gain a second pair. + let once = quoted_command(std::path::Path::new("C:\\v.exe")); + assert_eq!(quoted_command(std::path::Path::new(&once)), once); + } + + const PAIRING: Uuid128 = 0x0000_0000_0000_0000_0000_0000_0000_0001; + const CAPABILITY: Uuid128 = 0x0000_0000_0000_0000_0000_0000_0000_0002; + const ABSENT: Uuid128 = 0x0000_0000_0000_0000_0000_0000_0000_0099; + + /// A round of the shape the pairing flow uses — read capability, write a + /// frame without response, receive the notification it provokes — driven + /// entirely through `&dyn GattLink`. + /// + /// This is what the seam is FOR: the same caller runs against BlueZ, WinRT + /// or this fake, so the protocol flow can be tested with no radio. + #[tokio::test] + async fn a_caller_can_drive_the_link_through_the_trait() { + let mut fake = FakeGattLink::new(vec![PAIRING, CAPABILITY]); + fake.reads.insert(CAPABILITY, vec![0x01, 0x00, 0x00]); + let link: &dyn GattLink = &fake; + + assert_eq!(link.read(CAPABILITY).await.unwrap(), vec![0x01, 0x00, 0x00]); + + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + link.subscribe(PAIRING, tx).await.unwrap(); + link.write(PAIRING, b"msg1", false).await.unwrap(); + + // The peer answers on the characteristic we wrote to. + fake.push_notification(PAIRING, b"msg2".to_vec()); + assert_eq!(rx.recv().await.unwrap(), b"msg2".to_vec()); + + // Write-without-response is what §9.1 specifies for this path; a + // caller that flipped it would still "work" against real hardware but + // pay an ATT ack per frame. + let writes = fake.writes.lock().unwrap(); + assert_eq!(writes.len(), 1); + assert_eq!(writes[0], (PAIRING, b"msg1".to_vec(), false)); + } + + /// An absent characteristic must be reportable BEFORE a write is attempted + /// — that is how a peer without the audio-signal characteristic keeps + /// working instead of failing the connect. + #[tokio::test] + async fn an_absent_characteristic_is_visible_and_unwritable() { + let fake = FakeGattLink::new(vec![PAIRING]); + let link: &dyn GattLink = &fake; + assert!(link.has(PAIRING)); + assert!(!link.has(ABSENT)); + assert!(link.write(ABSENT, b"x", false).await.is_err()); + assert!(fake.writes.lock().unwrap().is_empty()); + } + + /// The trait has to be usable as a spawned, shared object — that is how the + /// BLE loop will hold it. Fails to compile if a signature stops being + /// `Send + Sync` or the futures stop being `Send`. + #[tokio::test] + async fn the_link_survives_being_shared_across_tasks() { + let link: std::sync::Arc = + std::sync::Arc::new(FakeGattLink::new(vec![PAIRING])); + let l2 = std::sync::Arc::clone(&link); + let joined = tokio::spawn(async move { + l2.write(PAIRING, b"from another task", false).await.unwrap(); + l2.is_connected().await + }) + .await + .unwrap(); + assert!(joined); + } +} diff --git a/linux/daemon/src/core/platform/toast_xml.rs b/linux/daemon/src/core/platform/toast_xml.rs new file mode 100644 index 0000000..3cffbec --- /dev/null +++ b/linux/daemon/src/core/platform/toast_xml.rs @@ -0,0 +1,154 @@ +//! The toast XML document a Windows notification is built from. +//! +//! # Why this is not inside the Windows module +//! +//! The dialect is Windows-only, but the code is pure string building — and it +//! is the one part of the notification path that handles text this machine did +//! not author. A mirrored phone notification's title and body go straight in +//! here, so a missing escape is not cosmetic: `&` or `<` in a message makes the +//! document unparseable and the notification silently vanishes, and text that +//! closes an element early can inject its own `` buttons into a prompt +//! the user is about to trust. +//! +//! Compiled on every platform so it can be tested on the machine this is +//! developed on, rather than being verified for the first time on Windows. + +/// Build the toast XML for a notification. +/// +/// `actions` is `(key, label)`; the key comes back as the activation argument, +/// so it is what the `fc:` / `call:` / `act:` consumers filter on. +pub fn toast_xml(summary: &str, body: &str, actions: &[(String, String)], urgent: bool) -> String { + let mut xml = String::from(""); + xml.push_str(""); + xml.push_str(&escape(summary)); + xml.push_str(""); + if !body.is_empty() { + xml.push_str(""); + xml.push_str(&escape(body)); + xml.push_str(""); + } + xml.push_str(""); + if !actions.is_empty() { + xml.push_str(""); + for (key, label) in actions { + xml.push_str(""); + } + xml.push_str(""); + } + xml.push_str(""); + xml +} + +/// Escape the five predefined XML entities. +/// +/// Ampersand first, and only once: doing it in any other order would rewrite +/// the `&` of an escape produced by an earlier replacement, turning `<` into +/// `&lt;` and showing the user the escape instead of the character. +pub fn escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn accept_decline() -> Vec<(String, String)> { + vec![ + ("fc:accept".to_string(), "Accept".to_string()), + ("fc:decline".to_string(), "Decline".to_string()), + ] + } + + #[test] + fn escapes_all_five_entities_ampersand_first() { + assert_eq!(escape("a&b"), "a&b"); + assert_eq!(escape(""), "<x>"); + assert_eq!(escape("\"q\" 'a'"), ""q" 'a'"); + // The ordering trap: an ampersand introduced by escaping `<` must not + // be escaped again. + assert_eq!(escape("<"), "<"); + assert!(!escape("<").contains("&")); + } + + /// The attack this file exists to prevent: a phone notification whose text + /// tries to close the document and add its own button to a consent prompt. + #[test] + fn remote_text_cannot_inject_an_action_button() { + let hostile = "hi\ + +
{{ phoneOnline ? t("peers.connected") : phoneConnecting ? t("peers.connecting") : t("peers.offline") }} + +
@@ -177,6 +280,35 @@ const earbudsStatus = computed(() => {
Charging
+ +
+
+
{{ t("peers.switch_pick") }}
+ + +
+
+
+
{{ t("peers.switch_none") }}
+
+
+
+
{{ t("peers.switch_scanning") }}
+ +
+
+
{{ t("peers.other_title") }}
+
{{ t("peers.other_hint") }}
+ +
+ + +
+ + +
+
{{ t("peers.add_pair") }}
+
{{ t("peers.add_pair_hint") }}
+
+ +
@@ -261,8 +442,51 @@ const earbudsStatus = computed(() => { @apply flex h-[42px] w-[42px] shrink-0 items-center justify-center rounded-xl border border-white/[0.06] bg-white/[0.05]; color: #e8eaed; } +/* The dot is drawn by a masked pseudo-element, not by a background colour + clipped with `border-radius`. It is eight CSS pixels — eleven device pixels at + a fractional display scale — and a clipped circle that small rasterises to a + different silhouette depending on the sub-pixel offset it happens to land on: + from the identical rule, the "This device" dot came out round and the phone's + came out a squircle. A radial mask is antialiased the same way wherever it + falls. It has to be a mask and not a gradient: a gradient fading to + `transparent` fades through black and leaves a dark rim at this size. + The colour rides on `currentColor` (text-primary, …) rather than bg-*. */ .vx-dot { - @apply h-2 w-2 shrink-0 rounded-full; + @apply relative h-[11px] w-[11px] shrink-0; +} +.vx-dot::before, +.vx-pulse::after { + content: ""; + position: absolute; + inset: 0; + background: currentColor; + -webkit-mask-image: radial-gradient(circle at 50% 50%, #000 0 45%, transparent 55%); + mask-image: radial-gradient(circle at 50% 50%, #000 0 45%, transparent 55%); +} +/* The halo: a second copy of the dot growing out of it and fading. See the + `vx-pulse` keyframes in style.css for why it scales rather than animating a + `box-shadow`, and why it steps rather than easing. */ +.vx-pulse::after { + pointer-events: none; + animation: vx-pulse 2.2s steps(33, end) infinite; +} +/* `drop-shadow`, not `box-shadow`: the glow has to follow the masked circle, + and a box-shadow would trace the square border box (and be masked away). */ +.vx-glow { + filter: drop-shadow(0 0 3px hsl(var(--primary) / 0.75)); +} +/* A smaller sibling of `.vx-icon` for the compact rows. Its own class rather + than `vx-icon` plus size utilities: Vue scoped styles compile to + `.vx-icon[data-v-hash]`, which out-specifies a plain `.h-[34px]`, so the + override would have been silently ignored. */ +.vx-row-icon { + @apply flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[10px] border border-white/[0.06] bg-white/[0.05]; + color: #e8eaed; +} +/* One "Also paired" row. The whole row is the button — there is a single + action per row, and the heading already says what it is. */ +.vx-row { + @apply flex w-full items-center gap-2.5 rounded-[10px] px-1 py-1.5 transition-colors hover:bg-white/[0.05] disabled:opacity-50; } .vx-chip { @apply inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.05] px-[13px] py-2 text-[12.5px] font-medium transition-colors hover:bg-white/[0.09] hover:text-foreground disabled:opacity-50; @@ -283,6 +507,13 @@ const earbudsStatus = computed(() => { color: hsl(var(--foreground)); background: hsl(var(--foreground) / 0.08); } +/* An action that just failed — held for a few seconds, with the reason in the + button's tooltip. Still, no pulse: this one is reporting, not working. */ +.vx-ring--bad { + color: hsl(var(--destructive)); + border-color: hsl(var(--destructive) / 0.45); + background: hsl(var(--destructive) / 0.12); +} .vx-ring--on { color: hsl(var(--primary)); border-color: hsl(var(--primary) / 0.4); diff --git a/linux/ui-tauri/src/style.css b/linux/ui-tauri/src/style.css index c564e0a..7b1cf09 100644 --- a/linux/ui-tauri/src/style.css +++ b/linux/ui-tauri/src/style.css @@ -100,15 +100,32 @@ } } -/* Design-system motion: the connection dot breathes. */ +/* Design-system motion: the connection dot breathes. The halo itself is drawn + in pages/home/Devices.vue (`.vx-pulse::after`), a masked copy of the dot; this + is only the motion. + + Two things here are load-bearing, both because the webview runs on the + software renderer on purpose (see the WEBKIT_DISABLE_DMABUF_RENDERER note in + src-tauri/src/main.rs): there is no compositor fast path, so every frame of + any animation repaints a good part of the window. + + * `transform` and `opacity`, never an animated `box-shadow`. The box-shadow + version of this one 8px dot cost ~55% of a core, for ever, on a window + showing nothing but "everything in sync". + * `steps()` rather than a smooth ease, which caps the halo at 15 updates a + second instead of the display's 60. On a soft fade nobody can tell, and it + is another 2x off the only thing this screen animates. + + The same reasoning applies to whatever else sits in the repainted area: see + the note on the logo import in components/Sidebar.vue, where a 512px PNG + rescaled to 30px on every frame was costing 60% of a core by itself. */ @keyframes vx-pulse { - 0% { box-shadow: 0 0 0 0 hsl(var(--primary) / 0.5); } - 70% { box-shadow: 0 0 0 7px hsl(var(--primary) / 0); } - 100% { box-shadow: 0 0 0 0 hsl(var(--primary) / 0); } + 0% { transform: scale(1); opacity: 0.5; } + 70%, 100% { transform: scale(2.75); opacity: 0; } } @layer utilities { - .vx-pulse { animation: vx-pulse 2.2s ease-out infinite; } + /* `.vx-pulse` only marks the dot; the halo it draws lives with `.vx-dot`. */ /* Small lowercase tag for a feature that ships as Experimental. SOLID (opaque) dark-amber fill so it reads cleanly wherever it sits — including diff --git a/shared/proto/vortex.proto b/shared/proto/vortex.proto index 0b8ca88..6c10e26 100644 --- a/shared/proto/vortex.proto +++ b/shared/proto/vortex.proto @@ -43,6 +43,9 @@ message VortexMessage { Ping ping = 20; Pong pong = 21; ErrorFrame error = 22; + + // --- Multi-peer session ownership (30–39) --- + PeerHandoff peer_handoff = 30; } } @@ -189,6 +192,44 @@ message ErrorFrame { string message = 2; // diagnostics only; must not leak secrets } +// ============================================================================ +// 3b. Multi-peer session ownership +// ============================================================================ + +// Transfers or refuses ownership of the single active session. +// +// A device may TRUST many peers but is ACTIVE with only one at a time. This +// frame is how the two sides agree on which. It is additive: a peer that does +// not advertise CAP_PEER_HANDOFF in `capability_flags` never receives one, and +// an older build ignores the unknown oneof field rather than failing. +// +// `RELEASE` is sent by the side giving up ownership — the user pressed +// "Switch" and a replacement peer has already been chosen. Ownership flips +// atomically when it is sent; the transport link may linger and drop on its +// own afterwards. That ordering matters: while two links overlap, only one +// side may be active, or both laptops mirror notifications and sync clipboard +// at once. +// +// `BUSY` is a refusal, sent when a peer asks to become active while another +// already is. Without an explicit refusal a rejected peer cannot distinguish +// "no" from packet loss, and retries in a tight loop against the phone's +// single GATT link. +message PeerHandoff { + enum Kind { + KIND_UNSPECIFIED = 0; + RELEASE = 1; // you are no longer the active peer + BUSY = 2; // refused: another peer is active + CLAIM = 3; // request to become the active peer + } + Kind kind = 1; + // Why ownership moved, for the UI ("switched to ") and for logs. + // Diagnostics only — never secrets, per the ErrorFrame.message rule. + string reason = 2; + // Display name of the peer taking over, when known. Sanitised by the + // receiver before it reaches any UI (it is peer-supplied text). + string successor_name = 3; +} + // ============================================================================ // 4. Shared enums // ============================================================================