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..5bd383f 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,13 @@ object FrameType { const val PHONE_FILES: Byte = 0x4F const val FRAG: Byte = 0x4E + /** Session-ownership handoff (design doc §D4). A device may TRUST many + * peers but is ACTIVE with exactly one; this frame is how the two sides + * agree which. `sub` carries the kind ([FrameSub.HANDOFF_RELEASE] etc.), + * the AEAD payload an optional UTF-8 successor name for the UI. + * Additive: both sides log-and-ignore unknown frame types, so a peer + * without this build is unaffected. Mirrors Rust `ty::PEER_HANDOFF`. */ + const val PEER_HANDOFF: Byte = 0x4F const val ERROR: Byte = 0x7F } @@ -160,6 +167,16 @@ object FrameSub { const val PONG: Byte = 0x02 const val ECHO_REQUEST: Byte = 0x01 const val ECHO_RESPONSE: Byte = 0x02 + /** [FrameType.PEER_HANDOFF] kinds. Mirror Rust `ty::sub::HANDOFF_*`. */ + /** "You are no longer my active peer" — sent by the side handing ownership + * over, so the receiver stops presenting itself as connected instead of + * finding out on next contact. */ + const val HANDOFF_RELEASE: Byte = 0x01 + /** Refused: another peer is already active. Explicit because silence is + * indistinguishable from packet loss and invites a retry loop. */ + const val HANDOFF_BUSY: Byte = 0x02 + /** Request to become the active peer. */ + const val HANDOFF_CLAIM: Byte = 0x03 } /** Header size in bytes. */ 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..d9d25ec 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,6 +169,18 @@ 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 = @@ -529,6 +541,30 @@ class GattServer( fun sendNotesSyncEncrypted(peerStaticPub: ByteArray, chunkPayload: ByteArray): Boolean = sealAndNotify(peerStaticPub, FrameType.NOTES_SYNC, chunkPayload, "sendNotesSync") + /** + * 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) @@ -731,6 +767,20 @@ class GattServer( fun hasActiveConnection(): Boolean = connectedAddrs.isNotEmpty() + /** + * 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 +837,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}") } @@ -1079,6 +1141,26 @@ class GattServer( Log.w(TAG, "onNotesSyncReceived 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}") + } + } + } } } 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..0ae4d07 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 @@ -20,51 +20,107 @@ object ClipboardFileReader { 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 read, 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() + /** Bigger than [MAX_FILE_BYTES]; [bytes] is the best size we know. */ + data class TooLarge(val bytes: Long) : Outcome() + /** Unreadable, empty, or it would not fit in memory. */ + data class Unreadable(val why: String) : Outcome() } - private fun readInner(context: Context, uri: Uri): ClipboardOutgoingFile? { + /** + * Read [uri] into memory, or explain why not. + * + * **The size is checked BEFORE the bytes are read.** It used to be checked + * after `readBytes()`, which made the guard unreachable for exactly the + * files it existed to stop: an 835 MB share allocated 876 MB against a + * 256 MB heap growth limit and threw `OutOfMemoryError` at the read. That + * is an `Error`, not an `Exception`, so the old `catch (e: Exception)` did + * not catch it — it escaped `ShareReceiverActivity.onCreate` and killed the + * whole process, taking the BLE/LAN service down with it. The user saw a + * crash and no explanation. + * + * `OutOfMemoryError` is still caught below, because a pre-check can only + * use the size the provider *reports*: `OpenableColumns.SIZE` is absent or + * -1 for plenty of providers, and a wrong one must not be able to kill the + * app either. + */ + /** 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 + + // Pre-flight: refuse before allocating anything. A negative or absent + // size means "provider doesn't know" — fall through and let the + // bounded read below decide. + val reported = reportedSize(context, uri) + if (reported > MAX_FILE_BYTES) { + Log.i(TAG, "file too large ($reported bytes > $MAX_FILE_BYTES) — not sent") + return Outcome.TooLarge(reported) } - 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 + + return try { + // Bounded even when the provider lied about (or omitted) the size: + // read at most the cap + 1 byte, so an oversized stream is detected + // without ever buffering it whole. + val bytes = cr.openInputStream(uri)?.use { it.readAtMost(MAX_FILE_BYTES + 1) } + when { + bytes == null -> Outcome.Unreadable("no input stream") + bytes.isEmpty() -> Outcome.Unreadable("empty file") + bytes.size > MAX_FILE_BYTES -> { + Log.i(TAG, "file exceeds cap (provider reported $reported) — not sent") + Outcome.TooLarge(maxOf(reported, bytes.size.toLong())) + } + else -> Outcome.Ok(ClipboardOutgoingFile(bytes, name, mime)) } - else -> ClipboardOutgoingFile(bytes, name, mime) + } catch (e: OutOfMemoryError) { + // Reachable only when the reported size was wrong/absent. Catching + // an Error is deliberate and narrow: the alternative is the process + // dying and every Vortex feature with it. + Log.w(TAG, "file read ran out of memory: ${e.message}") + Outcome.Unreadable("too large to buffer") + } catch (e: Exception) { + Log.w(TAG, "file read failed: ${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 + } + + /** Read at most [limit] bytes. Unlike `readBytes()` this never allocates + * more than the caller is prepared to accept. */ + private fun java.io.InputStream.readAtMost(limit: Long): ByteArray { + val cap = limit.coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + val out = java.io.ByteArrayOutputStream(minOf(cap, 64 * 1024)) + val buf = ByteArray(64 * 1024) + var total = 0 + while (total < cap) { + val n = read(buf, 0, minOf(buf.size, cap - total)) + if (n <= 0) break + out.write(buf, 0, n) + total += n + } + return out.toByteArray() } 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/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..09e8c16 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 @@ -890,16 +890,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 } 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..c89c26c --- /dev/null +++ b/android/app/src/main/java/com/vortex/a3/service/ShareQueue.kt @@ -0,0 +1,177 @@ +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.TooLarge -> { + failed++ + Log.w(TAG, "skipping $uri: over the size cap (${outcome.bytes} bytes)") + showProgress() + } + 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 `ClipboardBlobStore.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..0a9dc9a 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.clipboard.ClipboardBlobStore.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..7b09d59 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,6 +68,19 @@ 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 internal var gattServer: GattServer? = null /** Buffers phone→laptop notifications that fail to send while BLE is down; * flushed when the peer re-subscribes to AUDIO_SIGNAL. */ @@ -77,6 +91,19 @@ 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 /** 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 +119,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. */ @@ -730,6 +773,33 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { outcome.ciphers.receiver, ) val peerPub = outcome.peerStaticPub.copyOf() + val previousPeer = activePeerPub + activePeerPub = peerPub + // 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 (advertiser?.seeking == true && + (previousPeer == null || !previousPeer.contentEquals(peerPub)) + ) { + 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) @@ -762,16 +832,82 @@ 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 + // 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 } + } + val linkedPub = activePeerPub + all.filter { linkedPub == null || !it.peerStaticPub.contentEquals(linkedPub) } + .map { it.prs } + } + if (peerStore.list().isNotEmpty()) { + // No `isConnected` argument: `adv.linkedProvider` above answers the + // same question, and answers it better. Upstream passed + // `hasActiveConnection()`, which is merely ACL-connected — and + // BlueZ owns the ACL, so it outlives the laptop app. After a laptop + // restart the phone saw a "connection" with no session behind it, + // stayed silent, and became unreachable. `linkedProvider` keys on + // the audio-signal SUBSCRIPTION instead, which cannot outlive the + // session it belongs to. + 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 +915,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 @@ -1076,6 +1259,18 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { * 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/VortexStackClipboard.kt b/android/app/src/main/java/com/vortex/a3/service/VortexStackClipboard.kt index 01c8ae0..3c9df13 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 @@ -130,14 +130,14 @@ 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 + com.vortex.a3.core.clipboard.ClipboardFileReader.readOrNull(ctx, media.uri)?.bytes } val o = org.json.JSONObject() o.put("token", token) 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..67e6c90 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 @@ -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..43f0c93 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 @@ -64,6 +64,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 +177,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 +214,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 +228,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()}") } @@ -345,6 +365,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 +440,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, 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..b716ea8 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 @@ -63,6 +63,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 +77,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, @@ -257,6 +267,11 @@ 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, 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..6af5337 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 @@ -59,6 +59,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,6 +88,11 @@ 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, @@ -104,7 +110,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 @@ -263,6 +280,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 +298,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/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..68c0de4 --- /dev/null +++ b/docs/design/file-browsing.md @@ -0,0 +1,230 @@ +# Browsing the phone's files from the desktop + +**Status:** design, not implemented. **Targets:** Linux *and* Windows from day +one — the Windows port branch means every new feature needs both. + +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: WebDAV first, native VFS as the exit + +### v1 — WebDAV on loopback + +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 + +- **Linux:** FUSE. Straightforward, gives a real mount. +- **Windows:** **ProjFS** (Projected File System), shipped in Windows 10 1809+ + with **no third-party install** — it is what VFS for Git uses. This is the key + fact that beats WebDAV: a real filesystem, no size limits, proper seeking. + +More code (two presentation implementations), but no artificial ceilings, and +the phone side is untouched by the switch. + +### 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. + +--- + +## 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. +- 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. +- **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. +- **Coalescing and a concurrency cap.** Thumbnailers fire dozens of parallel + reads; unbounded, they will starve the link and the BLE session with it. +- **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. +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. +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. + +Steps 1–2 are worth doing regardless of whether the mount ever ships, which is +the main argument for this ordering. + +## 9. Open questions + +- **Windows `FileSizeLimitInBytes`:** ship a registry tweak in the installer, + document it, or skip straight to ProjFS? +- **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. +- **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/daemon/src/core/ble/audio_signal.rs b/linux/daemon/src/core/ble/audio_signal.rs index 80ff0e2..1c9b367 100644 --- a/linux/daemon/src/core/ble/audio_signal.rs +++ b/linux/daemon/src/core/ble/audio_signal.rs @@ -127,10 +127,18 @@ 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<([u8; 32], u8, Vec)>, + >, ) -> Result<(), String> { let char = client .audio_signal @@ -227,6 +235,7 @@ 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 { warn!( "audio-signal unexpected frame ty=0x{:02x}; ignoring", @@ -586,7 +595,7 @@ 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((peer_pub, frame.ty, plain[..n].to_vec())); } continue; } diff --git a/linux/daemon/src/core/ble/frame.rs b/linux/daemon/src/core/ble/frame.rs index 1d8a4bf..473c4c4 100644 --- a/linux/daemon/src/core/ble/frame.rs +++ b/linux/daemon/src/core/ble/frame.rs @@ -184,6 +184,16 @@ 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`. + pub const PEER_HANDOFF: u8 = 0x4F; pub const ERROR: u8 = 0x7F; } @@ -193,6 +203,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/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/pairing/handshake.rs b/linux/daemon/src/core/pairing/handshake.rs index b76d86a..8db18ee 100644 --- a/linux/daemon/src/core/pairing/handshake.rs +++ b/linux/daemon/src/core/pairing/handshake.rs @@ -334,7 +334,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| { diff --git a/linux/ui-tauri/src-tauri/Cargo.lock b/linux/ui-tauri/src-tauri/Cargo.lock index abc28fd..a5a45e4 100644 --- a/linux/ui-tauri/src-tauri/Cargo.lock +++ b/linux/ui-tauri/src-tauri/Cargo.lock @@ -5383,6 +5383,7 @@ dependencies = [ "gstreamer-app", "gtk", "hex", + "if-addrs", "image", "ksni", "rand", diff --git a/linux/ui-tauri/src-tauri/Cargo.toml b/linux/ui-tauri/src-tauri/Cargo.toml index 5a7a58f..c6dd47e 100644 --- a/linux/ui-tauri/src-tauri/Cargo.toml +++ b/linux/ui-tauri/src-tauri/Cargo.toml @@ -34,6 +34,10 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } hex = "0.4" +# Cross-platform interface addresses + netmasks (getifaddrs / GetAdaptersAddresses) +# — the Wi-Fi Direct "are we already on the same LAN" test must work on the +# Windows port too. Already in the lockfile transitively. +if-addrs = "0.13" # Bulk-sync hash gate: sha256 of the cached mirror JSON (matches the # phone's hash of the same bytes). sha2 = "0.10" diff --git a/linux/ui-tauri/src-tauri/src/arbiter.rs b/linux/ui-tauri/src-tauri/src/arbiter.rs new file mode 100644 index 0000000..34c3781 --- /dev/null +++ b/linux/ui-tauri/src-tauri/src/arbiter.rs @@ -0,0 +1,235 @@ +//! Active-peer arbiter: which trusted peer currently owns the session. +//! +//! A laptop may TRUST several phones but is ACTIVE with exactly one. The +//! distinction this module exists to enforce (design doc §D4): +//! +//! * **connected** — a transport link exists (BLE GATT and/or LAN). Several +//! can overlap harmlessly, and briefly do during a handoff. +//! * **active** — that peer owns the mirrored state: notifications, clipboard, +//! SMS/contacts/call-log pages, media. Exactly one, ever. +//! +//! Keeping them separate is what makes a handoff safe. If "connected" implied +//! "active", then connecting to the replacement before the old link finished +//! dropping would give two phones ownership at once — both mirroring +//! notifications and both syncing clipboard into the same laptop. With the +//! split, ownership flips atomically the moment a switch is confirmed and the +//! old transport can linger and die on its own schedule. +//! +//! A claim that loses gets [`Claim::Busy`] rather than silence, so the loser +//! can back off. A peer that cannot tell refusal from packet loss retries in a +//! tight loop against the phone's single GATT link. + +use std::collections::HashSet; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +/// Outcome of asking to become the active peer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Claim { + /// Caller is now the active peer (or already was). + Granted, + /// Refused — `current` owns the session. Maps to `PeerHandoff.BUSY`. + Busy { current: [u8; 32] }, +} + +struct State { + active: Option<[u8; 32]>, + connected: HashSet<[u8; 32]>, + /// Deadline of a user-initiated "switch" window, if one is open. + /// + /// Bounded because a switch means *seeking on top of a live connection* — + /// the most expensive radio state there is (design doc §D9). Press Switch, + /// walk into a room with no other device, and without this the scan runs + /// forever. + switching_until: Option, +} + +fn state() -> &'static Mutex { + static S: OnceLock> = OnceLock::new(); + S.get_or_init(|| { + Mutex::new(State { + active: None, + connected: HashSet::new(), + switching_until: None, + }) + }) +} + +/// The peer that currently owns the session, if any. +pub(crate) fn active() -> Option<[u8; 32]> { + state().lock().ok().and_then(|s| s.active) +} + +/// True when `peer_pub` owns the session. +pub(crate) fn is_active(peer_pub: &[u8; 32]) -> bool { + active().as_ref() == Some(peer_pub) +} + +/// Ask to become the active peer. +/// +/// Idempotent for the peer that already owns the session, so a reconnect of +/// the active peer never has to be special-cased by callers. +pub(crate) fn claim(peer_pub: &[u8; 32]) -> Claim { + let Ok(mut s) = state().lock() else { + // A poisoned lock must not wedge the link. Granting is the safe + // direction: the alternative is a laptop that can never own a session + // again until restart. + return Claim::Granted; + }; + match s.active { + Some(cur) if &cur != peer_pub => Claim::Busy { current: cur }, + Some(_) => Claim::Granted, + None => { + s.active = Some(*peer_pub); + tracing::info!(peer = %hex::encode(&peer_pub[..4]), "active peer claimed"); + Claim::Granted + } + } +} + +/// Move ownership to `peer_pub`, displacing whoever holds it. +/// +/// Only for an explicit user switch — the one case where "someone else is +/// active" is not a reason to refuse, because the user just said so. Returns +/// the displaced peer so the caller can send it `PeerHandoff.RELEASE`. +pub(crate) fn force_activate(peer_pub: &[u8; 32]) -> Option<[u8; 32]> { + let Ok(mut s) = state().lock() else { return None }; + let previous = s.active.filter(|p| p != peer_pub); + s.active = Some(*peer_pub); + s.switching_until = None; + tracing::info!( + peer = %hex::encode(&peer_pub[..4]), + displaced = ?previous.map(|p| hex::encode(&p[..4])), + "active peer switched" + ); + previous +} + +/// Give up ownership if `peer_pub` holds it. No-op for any other peer, so a +/// stale teardown cannot blank the current owner. +pub(crate) fn release(peer_pub: &[u8; 32]) { + if let Ok(mut s) = state().lock() { + if s.active.as_ref() == Some(peer_pub) { + s.active = None; + tracing::info!(peer = %hex::encode(&peer_pub[..4]), "active peer released"); + } + } +} + +/// Record that a transport link to `peer_pub` exists. +pub(crate) fn note_connected(peer_pub: &[u8; 32]) { + if let Ok(mut s) = state().lock() { + s.connected.insert(*peer_pub); + } +} + +/// Record that every transport link to `peer_pub` is gone. +/// +/// Deliberately does NOT release ownership: a BLE drop during RPA churn is +/// routine and the peer is still the one whose data the UI shows. Ownership +/// changes only on an explicit switch or forget. +pub(crate) fn note_disconnected(peer_pub: &[u8; 32]) { + if let Ok(mut s) = state().lock() { + s.connected.remove(peer_pub); + } +} + +/// Whether any transport link to `peer_pub` is currently up. +/// +/// Only the tests read this today. It stays because it is the other half of the +/// connected/active split this module exists for, and the `PeerHandoff.RELEASE` +/// path needs it: a displaced peer can only be *told* it was displaced while a +/// link to it is still up, otherwise the notice has to wait for next contact. +#[allow(dead_code)] +pub(crate) fn is_connected(peer_pub: &[u8; 32]) -> bool { + state() + .lock() + .map(|s| s.connected.contains(peer_pub)) + .unwrap_or(false) +} + +/// Open a bounded switch window: keep the current peer, start looking for +/// another remembered one. +pub(crate) fn begin_switch(ttl: Duration) { + if let Ok(mut s) = state().lock() { + s.switching_until = Some(Instant::now() + ttl); + tracing::info!(ttl_s = ttl.as_secs(), "switch window opened"); + } +} + +/// True while a switch window is open and unexpired. Reading it also closes an +/// expired window, so callers need no separate reaper. +pub(crate) fn is_switching() -> bool { + let Ok(mut s) = state().lock() else { return false }; + match s.switching_until { + Some(deadline) if Instant::now() < deadline => true, + Some(_) => { + s.switching_until = None; + tracing::info!("switch window expired"); + false + } + None => false, + } +} + +/// Close a switch window early (user cancelled, or a replacement was chosen). +pub(crate) fn end_switch() { + if let Ok(mut s) = state().lock() { + s.switching_until = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn peer(n: u8) -> [u8; 32] { + [n; 32] + } + + // NOTE: the arbiter is process-global, so these run in one test to keep a + // deterministic order rather than racing each other through the statics. + #[test] + fn ownership_lifecycle() { + let a = peer(1); + let b = peer(2); + + assert_eq!(claim(&a), Claim::Granted); + assert!(is_active(&a)); + // Re-claiming by the owner is idempotent — a reconnect must not be + // mistaken for a competing peer. + assert_eq!(claim(&a), Claim::Granted); + // A second peer is refused, and told who holds it. + assert_eq!(claim(&b), Claim::Busy { current: a }); + + // Losing the transport does NOT lose ownership: BLE drops during RPA + // churn are routine and must not blank the UI's data source. + note_connected(&a); + note_disconnected(&a); + assert!(is_active(&a)); + assert!(!is_connected(&a)); + + // An explicit switch displaces the owner and names the displaced peer + // so the caller can send it RELEASE. + assert_eq!(force_activate(&b), Some(a)); + assert!(is_active(&b)); + assert!(!is_active(&a)); + + // Releasing a peer that does not own anything is a no-op. + release(&a); + assert!(is_active(&b)); + release(&b); + assert_eq!(active(), None); + } + + #[test] + fn switch_window_expires_on_read() { + begin_switch(Duration::from_secs(60)); + assert!(is_switching()); + end_switch(); + assert!(!is_switching()); + // A zero TTL is already expired, and reading clears it. + begin_switch(Duration::from_millis(0)); + assert!(!is_switching()); + } +} diff --git a/linux/ui-tauri/src-tauri/src/ble.rs b/linux/ui-tauri/src-tauri/src/ble.rs index f84fef9..996c349 100644 --- a/linux/ui-tauri/src-tauri/src/ble.rs +++ b/linux/ui-tauri/src-tauri/src/ble.rs @@ -118,6 +118,40 @@ pub(crate) fn shutdown_link_blocking() { note_session_addr(None); } +/// Last BLE address we completed a Noise IK exchange with, per peer. +/// +/// Recorded only *after* IK succeeds, so the address is positively tied to +/// that `peer_static_pub` — before IK we merely believe an RPA belongs to the +/// peer whose presence token matched, and acting on a belief would let us +/// remove a stranger's BlueZ device object. +/// +/// Used by `Forget` to clean up the peer's BlueZ device object (see +/// [`forget_stale_device`]). Vortex deliberately creates no BT bond on Linux +/// (see the 2026-06-02 note in `pairing.rs`), so there is usually no *bond* +/// to drop here — but a cached device object with a stale RPA does linger, and +/// leaving it behind is what feeds the RPA-churn connect wedge on the next +/// pairing. Entries are dropped on forget; the map holds one small entry per +/// trusted peer, so it needs no eviction. +static PEER_BLE_ADDRS: std::sync::Mutex< + Option>, +> = std::sync::Mutex::new(None); + +/// Tie `addr` to `peer_pub` after a successful IK. +pub(crate) fn remember_peer_addr(peer_pub: &[u8; 32], addr: bluer::Address) { + if let Ok(mut g) = PEER_BLE_ADDRS.lock() { + g.get_or_insert_with(std::collections::HashMap::new) + .insert(*peer_pub, addr); + } +} + +/// Remove and return the address last tied to `peer_pub`, if any. +pub(crate) fn take_peer_addr(peer_pub: &[u8; 32]) -> Option { + PEER_BLE_ADDRS + .lock() + .ok() + .and_then(|mut g| g.as_mut().and_then(|m| m.remove(peer_pub))) +} + /// Ms since we last heard from the phone over any transport (huge if never). pub(crate) fn peer_contact_age_ms() -> u64 { let last = LAST_PEER_CONTACT_MS.load(std::sync::atomic::Ordering::Relaxed); @@ -263,6 +297,121 @@ fn note_discovery_health(discovering: bool) { ); } } +/// A trusted peer seen on air during a switch scan. +#[derive(Debug, Clone)] +pub(crate) struct PeerCandidate { + pub peer_static_pub: [u8; 32], + pub name: Option, + pub rssi: i16, +} + +/// Scan for trusted-presence beacons from trusted peers OTHER than `exclude`. +/// +/// [`expected_presence_tokens`] flattens every peer's tokens into one set, +/// which answers "is any trusted peer nearby" — enough for reconnect, but not +/// for a switch, which has to know *which* peer it found so it can leave the +/// active one out. So this builds the token→peer map instead. +/// +/// Excluding the active peer is what makes "Switch" coherent: the user pressed +/// it precisely because they do not want the device they are already on +/// (design doc §D3). +pub(crate) async fn scan_other_trusted_peers( + adapter: &bluer::Adapter, + peer_store: &Arc, + exclude: Option<[u8; 32]>, + wait: Duration, +) -> Vec { + use std::collections::HashMap; + use vortex_l3_daemon::core::crypto::presence::{current_bucket, derive_presence_token}; + + let peers = { + let store = peer_store.clone(); + tokio::task::spawn_blocking(move || store.list().unwrap_or_default()) + .await + .unwrap_or_default() + }; + let others: Vec<_> = peers + .into_iter() + .filter(|p| exclude.as_ref() != Some(&p.peer_static_pub)) + .collect(); + if others.is_empty() { + return Vec::new(); + } + + // token -> peer, over the same ±2 bucket window the reconnect path + // tolerates (clock skew / a Doze-deferred rotation). + let now_sec = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let bucket_now = current_bucket(now_sec, PRESENCE_ROTATION_SEC); + let mut by_token: HashMap<[u8; 8], ([u8; 32], Option)> = HashMap::new(); + for p in &others { + for d in [-2i64, -1, 0, 1, 2] { + let tok = derive_presence_token(&p.prs, (bucket_now as i64 + d) as u64); + by_token.insert(tok, (p.peer_static_pub, p.peer_name.clone())); + } + } + + let (tx, mut rx) = tokio::sync::mpsc::channel::(16); + let scan = { + let adapter = adapter.clone(); + tokio::spawn(async move { + let _ = run_filtered_scan(adapter, move |c| { + if !c.payload.flags.is_trusted_presence() { + return; + } + let Some((peer_pub, stored_name)) = by_token.get(&c.payload.payload_8) else { + return; + }; + let _ = tx.try_send(PeerCandidate { + peer_static_pub: *peer_pub, + // Prefer the live SCAN_RSP name, fall back to the name + // recorded at pairing. + name: c.local_name.clone().or_else(|| stored_name.clone()), + rssi: c.rssi.unwrap_or(0), + }); + }) + .await; + }) + }; + + // Collect for the whole window rather than stopping at the first hit: the + // point is to know whether there is ONE candidate (auto-connect) or + // several (ask the user), so an early return would make the picker + // depend on which phone happened to advertise first. + let mut found: HashMap<[u8; 32], PeerCandidate> = HashMap::new(); + let deadline = tokio::time::Instant::now() + wait; + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + break; + } + match tokio::time::timeout(remaining, rx.recv()).await { + Ok(Some(cand)) => { + // Keep the strongest sighting per peer — RSSI wobbles a lot + // between advertising events. + found + .entry(cand.peer_static_pub) + .and_modify(|e| { + if cand.rssi > e.rssi { + *e = cand.clone(); + } + }) + .or_insert(cand); + } + Ok(None) | Err(_) => break, + } + } + // Same abort+join discipline as find_trusted_presence_peer: bluer only + // issues StopDiscovery when the scan future is actually dropped, so + // without the join the next scan races a still-live discovery session. + scan.abort(); + let _ = scan.await; + let mut out: Vec<_> = found.into_values().collect(); + out.sort_by(|a, b| b.rssi.cmp(&a.rssi)); + out +} pub(crate) async fn find_trusted_presence_peer( adapter: &bluer::Adapter, @@ -719,7 +868,7 @@ pub(crate) async fn connect_bonded_or_scan( /// (live-observed: 67s walk-up reconnect, the user typed their password /// long before the eager unlock could fire). RPA entries are transient by /// nature — removing one can't lose anything durable. -async fn forget_stale_device(adapter: &bluer::Adapter, addr: bluer::Address) { +pub(crate) async fn forget_stale_device(adapter: &bluer::Adapter, addr: bluer::Address) { match tokio::time::timeout(Duration::from_secs(3), adapter.remove_device(addr)).await { Ok(Ok(())) => tracing::debug!(addr = %addr, "stale RPA entry removed from BlueZ"), Ok(Err(e)) => tracing::debug!(addr = %addr, "remove_device: {e} (ignored)"), @@ -792,7 +941,7 @@ pub(crate) async fn run_ble_persistent_loop( >, // Generic additive-frame channel (e.g. NOTES_SYNC) — the listener forwards // (frame_ty, payload) here; the owning feature module filters + handles it. - raw_frame_tx: tokio::sync::mpsc::UnboundedSender<(u8, Vec)>, + raw_frame_tx: tokio::sync::mpsc::UnboundedSender<([u8; 32], u8, Vec)>, notif_writer: Arc>>, clipboard_writer: Arc>>, clipboard_image_writer: Arc>>, @@ -964,6 +1113,24 @@ pub(crate) async fn run_ble_persistent_loop( }; consec_ik_fail = 0; tracing::info!("P2.13: BLE IK returned; peer_counter={}", outcome.peer_counter); + // IK proved this address really is this peer — safe to remember for + // Forget's BlueZ cleanup (see PEER_BLE_ADDRS), and to point the + // phone-specific caches at this peer. + remember_peer_addr(&peer.peer_static_pub, client.address); + crate::arbiter::note_connected(&peer.peer_static_pub); + // Ownership, separately from the link (design doc §D4). A refusal is + // logged rather than acted on for now: nothing sends `PeerHandoff.CLAIM` + // yet, so the only way to reach Busy is a second trusted phone + // connecting while one is active — worth seeing in the log. + if let crate::arbiter::Claim::Busy { current } = + crate::arbiter::claim(&peer.peer_static_pub) + { + tracing::warn!( + peer = %hex::encode(&peer.peer_static_pub[..4]), + active = %hex::encode(¤t[..4]), + "second peer connected while another is active; link up but not active" + ); + } let Some(transport) = outcome.transport else { tracing::error!("P2.13: IK outcome missing transport state — internal bug"); diff --git a/linux/ui-tauri/src-tauri/src/call_log.rs b/linux/ui-tauri/src-tauri/src/call_log.rs index 538c936..13e7266 100644 --- a/linux/ui-tauri/src-tauri/src/call_log.rs +++ b/linux/ui-tauri/src-tauri/src/call_log.rs @@ -11,9 +11,7 @@ use vortex_l3_daemon::core::call_log::{CallLogAssembler, CallLogEntry}; /// `~/.cache/vortex/call_log.json` — survives a daemon restart so the page /// shows the last-known list instantly while a fresh sync arrives. fn cache_path() -> Option { - let mut p = PathBuf::from(std::env::var_os("HOME")?); - p.push(".cache/vortex/call_log.json"); - Some(p) + crate::peer_cache::peer_file("call_log.json") } /// Validate a complete call-log JSON blob, persist it to the disk cache and @@ -60,15 +58,11 @@ pub(crate) fn cache_hash() -> String { // The twin of sms.rs's history store; see there for the model. fn history_path() -> Option { - let mut p = PathBuf::from(std::env::var_os("HOME")?); - p.push(".cache/vortex/call_log_history.json"); - Some(p) + crate::peer_cache::peer_file("call_log_history.json") } fn history_since_path() -> Option { - let mut p = PathBuf::from(std::env::var_os("HOME")?); - p.push(".cache/vortex/call_log_history.since"); - Some(p) + crate::peer_cache::peer_file("call_log_history.since") } /// The history watermark: newest call date we've synced, 0 = nothing yet. diff --git a/linux/ui-tauri/src-tauri/src/clipboard_sync.rs b/linux/ui-tauri/src-tauri/src/clipboard_sync.rs index 39452eb..64d1cce 100644 --- a/linux/ui-tauri/src-tauri/src/clipboard_sync.rs +++ b/linux/ui-tauri/src-tauri/src/clipboard_sync.rs @@ -51,6 +51,48 @@ pub(crate) static CLIPBOARD_SYNC: AtomicBool = AtomicBool::new(true); /// Hash of the last text that crossed the link in EITHER direction — the /// loop guard. When the watcher re-captures this exact text (e.g. right /// after we set it from a received sync), it isn't bounced back. +/// Content tokens whose bytes we already pulled, with when. +/// +/// The queued-token dedupe below only covers offers still WAITING. There is a +/// window between the laptop dequeuing an offer and the phone learning it was +/// served, and a re-announce landing inside it passes the queued check, gets +/// queued again, and is pulled a second time — which is how a 65-file share +/// arrived as 75 files with duplicates. +/// +/// Entries expire after [RECENT_PULL_TTL] so a *deliberate* re-share of the +/// same content still works. That window only has to outlast the +/// announce/serve race (seconds), not the user's patience. +static RECENTLY_PULLED: Mutex> = Mutex::new(Vec::new()); + +/// How long a pulled token stays suppressed. Long enough to cover the +/// re-announce race, short enough that re-sharing the same file on purpose is +/// not mysteriously ignored. +const RECENT_PULL_TTL: std::time::Duration = std::time::Duration::from_secs(60); + +/// Record that `token`'s bytes arrived, so a re-announce cannot re-queue it. +pub(crate) fn note_pulled(token: &str) { + if token.is_empty() { + return; + } + if let Ok(mut g) = RECENTLY_PULLED.lock() { + let now = std::time::Instant::now(); + g.retain(|(_, at)| now.duration_since(*at) < RECENT_PULL_TTL); + g.push((token.to_string(), now)); + } +} + +/// True when `token` was pulled within [RECENT_PULL_TTL]. +fn pulled_recently(token: &str) -> bool { + RECENTLY_PULLED + .lock() + .map(|g| { + let now = std::time::Instant::now(); + g.iter() + .any(|(t, at)| t == token && now.duration_since(*at) < RECENT_PULL_TTL) + }) + .unwrap_or(false) +} + static LAST_SYNC_SIG: Mutex = Mutex::new(String::new()); /// Watcher (blocking thread) → async sender channel. Set once by @@ -712,7 +754,14 @@ async fn flush_file_batch(batch: Vec) { .into_iter() // `seen` also collapses duplicates WITHIN the batch: a re-announce // can land inside the same debounce window as the original. - .filter(|o| !queued.contains(&o.token) && seen.insert(o.token.clone())) + // `pulled_recently` closes the dequeued-but-not-yet-acked window; + // `queued` covers offers still waiting; `seen` collapses duplicates + // inside one debounce window. + .filter(|o| { + !queued.contains(&o.token) + && !pulled_recently(&o.token) + && seen.insert(o.token.clone()) + }) .collect() }; if batch.is_empty() { diff --git a/linux/ui-tauri/src-tauri/src/cmd_pairing.rs b/linux/ui-tauri/src-tauri/src/cmd_pairing.rs index c9bc61a..1a4d1ef 100644 --- a/linux/ui-tauri/src-tauri/src/cmd_pairing.rs +++ b/linux/ui-tauri/src-tauri/src/cmd_pairing.rs @@ -21,7 +21,7 @@ use crate::worker_ctx::WorkerCtx; /// /// Clipboard history is deliberately NOT wiped: it's a laptop-local feature /// (the Super+V popup), not the peer's data, so it outlives the link. -fn purge_peer_cache(app: &tauri::AppHandle) { +pub(crate) fn purge_peer_cache(app: &tauri::AppHandle) { crate::contacts::clear(app); crate::call_log::clear(app); crate::sms::clear(app); @@ -126,6 +126,193 @@ pub(crate) async fn pair( } } +/// How long a switch window stays open (design doc §D9). Long enough to walk +/// to another machine and wake it, short enough that an unattended press stops +/// scanning-on-top-of-a-live-link — the most expensive radio state we have. +const SWITCH_WINDOW_SECS: u64 = 45; + +/// One candidate device offered by a switch scan. +#[derive(serde::Serialize, Clone)] +struct SwitchCandidateDto { + peer_static_pub: String, + name: Option, + rssi: i16, +} + +/// `UiCmd::SwitchPeer` — keep the current peer, look for another trusted one. +/// +/// Explicitly NOT a release: the active link is held for the whole scan, so +/// the persistent reconnect loop has nothing to race back into and the laptop +/// cannot end up connected to nothing. Ownership only moves in +/// [`activate_peer`], once a replacement is actually in hand (§D3). +/// +/// Returns immediately and does the scan on a spawned task. The worker's +/// command loop is strictly sequential — awaiting a 45 s scan here would stall +/// every other command behind it, including the 5 s earbuds heartbeat. Same +/// reason `UiCmd::Scan` spawns rather than awaiting. +pub(crate) fn switch_peer(ctx: &WorkerCtx) { + // A second press while a window is open is a no-op rather than a second + // scan: two concurrent discoveries would fight over the adapter. + if crate::arbiter::is_switching() { + tracing::debug!("switch already in progress; ignoring"); + return; + } + let active = crate::arbiter::active(); + crate::arbiter::begin_switch(Duration::from_secs(SWITCH_WINDOW_SECS)); + + let app = ctx.app.clone(); + let adapter = ctx.adapter.clone(); + let peer_store = ctx.peer_store.clone(); + tokio::spawn(async move { + let _ = app.emit("vortex:switch_scanning", true); + let candidates = crate::ble::scan_other_trusted_peers( + &adapter, + &peer_store, + active, + Duration::from_secs(SWITCH_WINDOW_SECS), + ) + .await; + let _ = app.emit("vortex:switch_scanning", false); + + // Cancelled while we were scanning — drop the result rather than + // acting on a switch the user already backed out of. + if !crate::arbiter::is_switching() { + tracing::info!("switch window closed during scan; discarding candidates"); + return; + } + + let dtos: Vec = candidates + .iter() + .map(|c| SwitchCandidateDto { + peer_static_pub: hex::encode(c.peer_static_pub), + name: c.name.clone(), + rssi: c.rssi, + }) + .collect(); + tracing::info!(count = dtos.len(), "switch scan finished"); + + match candidates.as_slice() { + // Nothing else in range: report it and close, leaving the current + // peer untouched. The UI shows "no other device found". + [] => { + crate::arbiter::end_switch(); + let _ = app.emit("vortex:switch_candidates", dtos); + } + // Exactly one — no point asking which. + [only] => { + do_activate(&app, &peer_store, only.peer_static_pub).await; + } + // Several: let the user pick (§D8). + _ => { + let _ = app.emit("vortex:switch_candidates", dtos); + } + } + }); +} + +/// `UiCmd::CancelSwitch` — close the window, change nothing. +pub(crate) async fn cancel_switch(ctx: &WorkerCtx) { + crate::arbiter::end_switch(); + let _ = ctx.app.emit("vortex:switch_scanning", false); + let _ = ctx + .app + .emit::>("vortex:switch_candidates", Vec::new()); +} + +/// `UiCmd::ActivatePeer` — hand session ownership to this trusted peer. +pub(crate) async fn activate_peer(ctx: &WorkerCtx, hex_str: String) { + let Ok(bytes) = hex::decode(&hex_str) else { return }; + if bytes.len() != 32 { + return; + } + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + do_activate(&ctx.app, &ctx.peer_store, arr).await; +} + +/// Shared body of "adopt this peer as the active one", callable both from the +/// command handler and from the spawned switch scan. +/// +/// The ownership flip is atomic (§D4): the displaced peer stops being active +/// the instant this runs, even though its transport link may take a while to +/// drop. Without that ordering two phones would briefly both own the session +/// and both mirror notifications and clipboard into this laptop. +async fn do_activate( + app: &tauri::AppHandle, + peer_store: &std::sync::Arc, + peer_pub: [u8; 32], +) { + let successor_name = { + let ps = peer_store.clone(); + tokio::task::spawn_blocking(move || ps.load(&peer_pub).ok().and_then(|p| p.peer_name)) + .await + .unwrap_or(None) + }; + // Refuse to activate a peer we do not actually trust — for the command + // path the hex arrives from the webview, so it is untrusted input. + let ps = peer_store.clone(); + let known = tokio::task::spawn_blocking(move || ps.load(&peer_pub).is_ok()) + .await + .unwrap_or(false); + if !known { + tracing::warn!(peer = %hex::encode(&peer_pub[..4]), "activate: not a trusted peer"); + return; + } + + let displaced = crate::arbiter::force_activate(&peer_pub); + crate::arbiter::end_switch(); + if let Some(prev) = displaced { + send_release(&prev, successor_name).await; + } + let _ = app.emit("vortex:switch_scanning", false); + let _ = app.emit::>("vortex:switch_candidates", Vec::new()); + // Blank the pages that were showing the old phone's data, then re-emit + // peers so the UI's `active` flags follow the new owner. + purge_peer_cache(app); + emit_peers(app, peer_store.clone()).await; +} + + +/// Tell `peer_pub` it is no longer the active peer. +/// +/// Best-effort by nature: it can only be delivered while a link to that peer is +/// still up. That is the normal case here — a switch is confirmed while the +/// displaced peer is still the live BLE session (see `BLE_SEALED_WRITER`) — but +/// if the link already dropped, the peer simply learns on next contact, which is +/// the behaviour we had before this frame existed. So a failure is logged at +/// debug, not surfaced: nothing is broken by it. +async fn send_release(peer_pub: &[u8; 32], successor_name: Option) { + use vortex_l3_daemon::core::ble::frame::{sub, ty}; + let Some(holder) = crate::BLE_SEALED_WRITER.get() else { + tracing::debug!("RELEASE not sent: no BLE session holder yet"); + return; + }; + let writer = { holder.lock().await.clone() }; + let Some(writer) = writer else { + tracing::debug!( + peer = %hex::encode(&peer_pub[..4]), + "RELEASE not sent: no live BLE session" + ); + return; + }; + // Payload is the successor's display name, purely so the phone can say + // "moved to ". Empty when unknown — the sub code is what carries + // meaning, so an absent name must not change behaviour. + let payload = successor_name.unwrap_or_default().into_bytes(); + // The sealed writer takes only a frame type, so the kind rides as the first + // payload byte rather than Frame.sub. Receivers read it back the same way. + let mut body = Vec::with_capacity(payload.len() + 1); + body.push(sub::HANDOFF_RELEASE); + body.extend_from_slice(&payload); + match writer(ty::PEER_HANDOFF, body).await { + Ok(()) => tracing::info!( + peer = %hex::encode(&peer_pub[..4]), + "sent PeerHandoff.RELEASE to the displaced peer" + ), + Err(e) => tracing::debug!("RELEASE send failed: {e}"), + } +} + /// `UiCmd::ForgetPeer` — forget locally now (instant UI), then best-effort /// background revoke retries for up to 60 s so trust drops bidirectionally. pub(crate) async fn forget_peer(ctx: &WorkerCtx, hex_str: String) { @@ -162,8 +349,24 @@ pub(crate) async fn forget_peer(ctx: &WorkerCtx, hex_str: String) { } Err(e) => tracing::warn!("peer_store.forget JOIN ERROR: {}", e), } + // Drop the peer's BlueZ device object too. Vortex creates no BT bond on + // Linux (see the 2026-06-02 note in `pairing.rs`), so this is normally not + // a *bond* removal — it evicts the cached device entry whose stale RPA + // otherwise gets re-served from the adapter's advertisement cache and + // burns connect timeouts on the next pairing. If a bond *does* exist + // (added by hand in the desktop's Bluetooth panel, or by an older build), + // this drops it, which is what keeps the two sides from ending up in the + // one-sided-bond state that fails with `timeout: service discovery`. + if let Some(addr) = crate::ble::take_peer_addr(&arr) { + crate::ble::forget_stale_device(&ctx.adapter, addr).await; + } // Drop all of the forgotten phone's cached data + blank its UI pages. + // Order matters: clear the in-page state (which reads the still-active + // paths) BEFORE dropping the peer's directory and unsetting it. purge_peer_cache(&ctx.app); + crate::peer_cache::remove_peer_dir(&arr); + crate::arbiter::release(&arr); + crate::arbiter::note_disconnected(&arr); emit_peers(&ctx.app, ctx.peer_store.clone()).await; // Background revoke retries (best-effort). Peer may be offline now; keep // trying for up to 60 s so a peer that comes back inside that window still @@ -197,6 +400,18 @@ pub(crate) async fn forget_peer(ctx: &WorkerCtx, hex_str: String) { /// `UiCmd::ForgetAll` — drop every trusted peer (local only). pub(crate) async fn forget_all(ctx: &WorkerCtx) { + // Collect the pubkeys before forgetting so the BlueZ cleanup below still + // knows which peers existed (the store is empty by then). + let ps = ctx.peer_store.clone(); + let pubs = tokio::task::spawn_blocking(move || { + ps.list() + .unwrap_or_default() + .into_iter() + .map(|p| p.peer_static_pub) + .collect::>() + }) + .await + .unwrap_or_default(); let ps = ctx.peer_store.clone(); let _ = tokio::task::spawn_blocking(move || { if let Ok(list) = ps.list() { @@ -212,6 +427,15 @@ pub(crate) async fn forget_all(ctx: &WorkerCtx) { } }) .await; + // Same BlueZ + per-peer cache cleanup as `forget_peer`, for every peer. + for peer_pub in &pubs { + if let Some(addr) = crate::ble::take_peer_addr(peer_pub) { + crate::ble::forget_stale_device(&ctx.adapter, addr).await; + } + crate::peer_cache::remove_peer_dir(peer_pub); + crate::arbiter::release(peer_pub); + crate::arbiter::note_disconnected(peer_pub); + } purge_peer_cache(&ctx.app); emit_peers(&ctx.app, ctx.peer_store.clone()).await; } diff --git a/linux/ui-tauri/src-tauri/src/contacts.rs b/linux/ui-tauri/src-tauri/src/contacts.rs index 3307b45..43ec8c2 100644 --- a/linux/ui-tauri/src-tauri/src/contacts.rs +++ b/linux/ui-tauri/src-tauri/src/contacts.rs @@ -11,9 +11,7 @@ use vortex_l3_daemon::core::contacts::{Contact, ContactsAssembler}; /// `~/.cache/vortex/contacts.json` — survives a daemon restart so the page /// shows the last-known list instantly while a fresh sync arrives. fn cache_path() -> Option { - let mut p = PathBuf::from(std::env::var_os("HOME")?); - p.push(".cache/vortex/contacts.json"); - Some(p) + crate::peer_cache::peer_file("contacts.json") } /// Validate a complete contacts JSON blob, persist it to the disk cache and diff --git a/linux/ui-tauri/src-tauri/src/handoff.rs b/linux/ui-tauri/src-tauri/src/handoff.rs index afa980e..19b5f75 100644 --- a/linux/ui-tauri/src-tauri/src/handoff.rs +++ b/linux/ui-tauri/src-tauri/src/handoff.rs @@ -1,7 +1,9 @@ //! Browsing HANDOFF consumer (laptop): a phone `HandoffEvent` → continue the //! page here, continuity-style. //! -//! - `open_now = true` (an explicit Share) → open the URL right away. +//! - `open_now = true` (an explicit Share) → open the URL right away, +//! and exactly once per request — the event is re-delivered by every AppState +//! heartbeat, so the open path is idempotent on `id` (see [LAST_OPENED]). //! - `open_now = false` (the live accessibility read) → show a top-bar PILL //! badged with the SITE's domain + favicon; one click opens the page. An //! empty `url` clears it. @@ -28,6 +30,22 @@ const HANDOFF_PILL_KEY: &str = "vortex-handoff"; /// away while the favicon was downloading). static CURRENT_URL: Mutex = Mutex::new(String::new()); +/// The `open_now` request we have already opened, so a heartbeat re-delivery +/// does not open it a second time. +/// +/// An explicit Share is a one-shot COMMAND, but it rides the phone's AppState +/// snapshot as a backstop for a dead BLE link — and a snapshot is republished +/// every ~12s. This branch used to call [open_url] unconditionally, so one +/// shared link became a browser tab every 12s until the phone app was killed; +/// 67 zombie `xdg-open` children had piled up under the app when it was caught. +/// Nothing the user did on the phone could stop it: only the accessibility read +/// ever clears the carried event, and copying other text does not touch it. +/// +/// Keyed on the request `id`, NOT the URL, so deliberately re-sharing the same +/// page still opens it. Falls back to the URL for phone builds that predate +/// `id` — those cannot express "again", and stopping the loop matters more. +static LAST_OPENED: Mutex = Mutex::new(String::new()); + /// The handoff consumer's sender, so an AppState-carried handoff (the LAN /// backstop) can be fed in alongside the dedicated BLE HANDOFF frame. Set once /// at worker start. @@ -86,7 +104,29 @@ pub(crate) fn spawn_consumer( continue; } if ev.open_now { - open_url(&ev.url); + // Open EXACTLY once per request, however many times it is + // re-delivered (heartbeat backstop, or the BLE frame and the + // AppState carry both landing — which used to open two tabs). + let token = if ev.id.is_empty() { + ev.url.clone() + } else { + ev.id.clone() + }; + let fresh = match LAST_OPENED.lock() { + Ok(mut g) if *g != token => { + *g = token; + true + } + // Already opened, or the lock is poisoned. Either way the + // safe answer is "don't open" — a missed share is a nuisance, + // an unstoppable browser is what we are fixing. + _ => false, + }; + if fresh { + open_url(&ev.url); + } else { + tracing::debug!("handoff: share already opened; ignoring re-assert"); + } continue; } // Live read → a "continue" pill badged with the site domain + icon. @@ -313,8 +353,13 @@ fn ensure_favicon(domain: &str) -> Option { } /// Open `url` in the default browser. The URL is never logged. +/// +/// `tokio::process`, not `std::process`: a `std` `Child` dropped without +/// `wait()` stays a zombie for the parent's whole life, and this app runs for +/// days. Tokio's orphan reaper collects the child on drop, so nothing +/// accumulates. (The notification-action opener already does it this way.) fn open_url(url: &str) { - match std::process::Command::new("xdg-open").arg(url).spawn() { + match tokio::process::Command::new("xdg-open").arg(url).spawn() { Ok(_) => tracing::info!("handoff: opened a shared page in the browser"), Err(e) => tracing::warn!("handoff: xdg-open failed: {e}"), } diff --git a/linux/ui-tauri/src-tauri/src/ipc.rs b/linux/ui-tauri/src-tauri/src/ipc.rs index ab8384b..2d8307d 100644 --- a/linux/ui-tauri/src-tauri/src/ipc.rs +++ b/linux/ui-tauri/src-tauri/src/ipc.rs @@ -41,6 +41,19 @@ pub(crate) enum UiCmd { StartMirror { width: u32, height: u32, fps: u32, bitrate: u32 }, /// Stop the active screen-mirror session. StopMirror, + /// "Switch device" on the connected card: keep the current phone, and + /// start looking for another *already-trusted* one. + /// + /// Deliberately not a release — the link is held until a replacement is + /// confirmed, so the reconnect loop has nothing to race back into and the + /// laptop can never end up connected to nothing (design doc §D3). + SwitchPeer, + /// Close the switch window without changing anything (user cancelled, or + /// it expired). + CancelSwitch, + /// Adopt this trusted peer (hex `peer_static_pub`) as the active one — + /// either the single candidate found, or the user's pick from several. + ActivatePeer(String), } /// Identity surface visible to the Vue layer. We deliberately keep @@ -67,6 +80,10 @@ pub(crate) struct TrustedPeerDto { peer_static_pub: String, paired_at: u64, peer_name: Option, + /// True for the peer that currently owns the session. With several + /// trusted phones the UI has to distinguish "remembered" from "the one + /// whose SMS and notifications you are looking at" — see `arbiter`. + active: bool, } /// Per-peer AppState snapshot pushed to the UI so it can render @@ -256,6 +273,7 @@ pub(crate) async fn emit_peers(app: &AppHandle, store: Arc) { let dtos: Vec = list .into_iter() .map(|p| TrustedPeerDto { + active: crate::arbiter::is_active(&p.peer_static_pub), peer_static_pub: hex::encode(p.peer_static_pub), paired_at: p.paired_at, peer_name: p.peer_name, diff --git a/linux/ui-tauri/src-tauri/src/lan.rs b/linux/ui-tauri/src-tauri/src/lan.rs index 2557d10..0f17061 100644 --- a/linux/ui-tauri/src-tauri/src/lan.rs +++ b/linux/ui-tauri/src-tauri/src/lan.rs @@ -19,7 +19,7 @@ use crate::{app_state_to_dto, emit_peers}; /// the gateway fallback when mDNS can't resolve the peer over its hotspot. pub(crate) const LAN_DEFAULT_PORT: u16 = 51820; -use crate::lan_wifi_direct::{restore_wifi, wd_active, WIFI_DIRECT_GO_IP}; +use crate::lan_wifi_direct::{wd_active, WIFI_DIRECT_GO_IP}; use crate::lan_state::{dispatch_appstate_call, dispatch_lock_command}; /// Last peer IP that mDNS successfully resolved to. When mDNS later comes @@ -79,9 +79,7 @@ async fn wait_for_settled( } fn last_peer_ip_path() -> Option { - let mut p = std::path::PathBuf::from(std::env::var_os("HOME")?); - p.push(".cache/vortex/last_peer_ip"); - Some(p) + crate::peer_cache::peer_file("last_peer_ip") } /// Is a phone→laptop file pull waiting on the next heartbeat round? The pull is @@ -117,6 +115,23 @@ pub(crate) fn note_queue_progress() { /// [`files_queued`] — is what may drive the heartbeat harder: a queue that is /// permanently stuck must not spin a TCP+IK every 2 s for the rest of the /// session, which is exactly what the unconditional form would do. +/// How long since a queued file last completed, if anything ever has. +/// +/// Raw progress, deliberately NOT ANDed with [`files_queued`] the way +/// [`file_pull_active`] is. A paced sender makes the pull queue oscillate +/// empty→full, so "queue is empty right now" says nothing about health — using +/// it as a stall signal force-restored Wi-Fi in the middle of a perfectly +/// healthy 65-file batch. +/// The peer IP that last worked, if any. Read by the Wi-Fi Direct gate to ask +/// "is the phone already on our LAN?" before paying for a P2P group. +pub(crate) fn last_good_peer_ip() -> Option { + *LAST_GOOD_PEER_IP.lock().unwrap_or_else(|e| e.into_inner()) +} + +pub(crate) fn queue_progress_age() -> Option { + QUEUE_PROGRESS_AT.lock().ok().and_then(|g| *g).map(|t| t.elapsed()) +} + pub(crate) fn file_pull_active() -> bool { if !files_queued() { return false; @@ -767,6 +782,9 @@ pub(crate) async fn try_lan_reconnect( .get() .and_then(|m| m.lock().ok().and_then(|mut g| g.pop_front())); if let Some((token, name, mime, id, kind)) = meta { + // Before anything else: this token's bytes have + // arrived, so a re-announce must not re-queue it. + crate::clipboard_sync::note_pulled(&token); note_queue_progress(); let offer = crate::clipboard_sync::Offer { kind, @@ -882,11 +900,17 @@ pub(crate) async fn try_lan_reconnect( // Wi-Fi Direct: once every queued file is pulled over the group // link, hop back to the normal Wi-Fi; otherwise pull the next now. if wd_active() { - if !files_queued() { - tracing::info!("Wi-Fi Direct: all files pulled → restoring Wi-Fi"); - restore_wifi(app).await; - } else if let Some(n) = crate::SYNC_NUDGE.get() { - n.notify_one(); + let queue_empty = !files_queued(); + // Hold the group link across brief gaps. A paced batch + // empties the queue between windows, and restoring on each + // gap cost a Wi-Fi disconnect/reconnect every few seconds. + crate::lan_wifi_direct::restore_when_idle(app, queue_empty).await; + if !queue_empty { + // More to pull → fetch the next one now rather than + // waiting out the heartbeat interval. + if let Some(n) = crate::SYNC_NUDGE.get() { + n.notify_one(); + } } } // SecretService D-Bus can stall here for hundreds of diff --git a/linux/ui-tauri/src-tauri/src/lan_state.rs b/linux/ui-tauri/src-tauri/src/lan_state.rs index 86d25a7..557ec40 100644 --- a/linux/ui-tauri/src-tauri/src/lan_state.rs +++ b/linux/ui-tauri/src-tauri/src/lan_state.rs @@ -198,7 +198,8 @@ pub(crate) fn spawn_state_consumer( dispatch_appstate_call(&state.call); // Browsing-handoff backstop: the page the phone is on, // carried in this STATE frame (when the BLE HANDOFF frame - // didn't get through). Consumer dedups by URL. + // didn't get through). Re-delivered on every heartbeat, so + // a Share carried here is opened once (deduped by id). crate::handoff::dispatch_appstate_handoff(&state.handoff); // Laptop→phone screen mirror over the BLE STATE path too. crate::laptop_cast::dispatch_request(state.laptop_mirror_req, state.laptop_mirror_extend); diff --git a/linux/ui-tauri/src-tauri/src/lan_wifi_direct.rs b/linux/ui-tauri/src-tauri/src/lan_wifi_direct.rs index 3d33f2f..0e95f23 100644 --- a/linux/ui-tauri/src-tauri/src/lan_wifi_direct.rs +++ b/linux/ui-tauri/src-tauri/src/lan_wifi_direct.rs @@ -24,6 +24,69 @@ pub(crate) struct WdState { pub(crate) static WIFI_DIRECT: std::sync::Mutex> = std::sync::Mutex::new(None); +/// When the pull queue last went empty while we were on the group link. +/// `None` = not idle (files pending, or not on the group). +static WD_IDLE_SINCE: std::sync::Mutex> = + std::sync::Mutex::new(None); + +/// How long the group link is held after the pull queue drains. +/// +/// Restoring the instant the queue emptied made a paced batch thrash the Wi-Fi: +/// the phone releases files in windows, so the queue legitimately goes empty +/// between them, and each gap produced a full leave-group + rejoin. Observed +/// live — restore at 15:16:53, rejoin at 15:16:57 — one disconnect/reconnect +/// notification per gap, dozens over a 65-file share. +/// +/// Sized under the phone's 60 s idle GO teardown, so we let go before the group +/// disappears underneath us, and the 60 s force-restore watchdog still bounds +/// the worst case. +const WD_IDLE_GRACE: Duration = Duration::from_secs(25); + +/// How long a pull may make NO progress before the watchdog restores Wi-Fi. +/// Bounds a genuinely stuck transfer without capping a healthy long one. +const WD_STALL_TIMEOUT: Duration = Duration::from_secs(60); + +/// Restore the normal Wi-Fi only once the queue has been empty for +/// [`WD_IDLE_GRACE`]. Call on every heartbeat round while on the group link. +/// +/// `queue_empty` is passed in rather than read here so the caller's notion of +/// "queued" stays the single source of truth. +pub(crate) async fn restore_when_idle(app: &AppHandle, queue_empty: bool) { + if !wd_active() { + if let Ok(mut g) = WD_IDLE_SINCE.lock() { + *g = None; + } + return; + } + if !queue_empty { + // More to pull — hold the link and reset the idle clock. + if let Ok(mut g) = WD_IDLE_SINCE.lock() { + *g = None; + } + return; + } + let elapsed = { + let Ok(mut g) = WD_IDLE_SINCE.lock() else { return }; + let since = g.get_or_insert_with(std::time::Instant::now); + since.elapsed() + }; + if elapsed < WD_IDLE_GRACE { + tracing::debug!( + idle_s = elapsed.as_secs(), + "Wi-Fi Direct: queue empty but holding the group link" + ); + return; + } + tracing::info!( + idle_s = elapsed.as_secs(), + "Wi-Fi Direct: idle past the grace window → restoring Wi-Fi" + ); + if let Ok(mut g) = WD_IDLE_SINCE.lock() { + *g = None; + } + restore_wifi(app).await; +} + pub(crate) fn wd_active() -> bool { WIFI_DIRECT.lock().map(|g| g.is_some()).unwrap_or(false) } @@ -102,6 +165,105 @@ pub(crate) async fn restore_wifi(app: &AppHandle) { let _ = app.emit("vortex:wifi-direct", false); } +/// Are we already on the same local network as `peer`, over a link fast enough +/// that a P2P group would not be worth a disconnect? +/// +/// Wi-Fi Direct costs BOTH devices their AP association — unavoidable with one +/// radio — so it should only be paid when the ordinary path cannot do the job. +/// Two devices on the same AP already have a perfectly good route through it. +/// +/// Portable by construction, because the Windows port needs this too: +/// +/// * the local address that would reach `peer` comes from a connected UDP +/// socket. `connect` on UDP sends nothing; it just asks the routing table, +/// and `local_addr` then reports the answer. Works the same on Windows. +/// * the netmask for that address comes from `if_addrs`, which wraps +/// `getifaddrs` on Unix and `GetAdaptersAddresses` on Windows. +/// +/// The one part that is genuinely platform-specific is deciding whether an +/// interface is a *fast* LAN link, which is why it lives in +/// [`is_fast_lan_iface`] on its own. +fn peer_on_same_fast_lan(peer: std::net::IpAddr) -> bool { + let Some(local) = local_addr_toward(peer) else { + return false; // no route we can name → let Wi-Fi Direct try + }; + let Ok(ifaces) = if_addrs::get_if_addrs() else { + return false; + }; + for iface in ifaces { + if iface.addr.ip() != local { + continue; + } + if !is_fast_lan_iface(&iface.name) { + tracing::debug!( + iface = %iface.name, + "route to peer is not a fast LAN link; Wi-Fi Direct still worthwhile" + ); + return false; + } + // Same interface AND same subnet: the AP path already reaches them. + let same_subnet = match (iface.addr, peer) { + (if_addrs::IfAddr::V4(v4), std::net::IpAddr::V4(p)) => { + let mask = u32::from(v4.netmask); + u32::from(v4.ip) & mask == u32::from(p) & mask + } + // v6 link-local/ULA subnetting is not what this decision hinges on. + _ => false, + }; + if same_subnet { + tracing::info!( + iface = %iface.name, %peer, + "peer already reachable on our LAN — skipping Wi-Fi Direct" + ); + return true; + } + } + false +} + +/// Which local address the OS would use to reach `peer`. +/// +/// A connected UDP socket is the portable way to ask: no packet is sent, the +/// kernel just does the route lookup so `local_addr` can report the source it +/// would pick. Port 9 (discard) is conventional for this and never contacted. +fn local_addr_toward(peer: std::net::IpAddr) -> Option { + let bind: &str = if peer.is_ipv4() { "0.0.0.0:0" } else { "[::]:0" }; + let sock = std::net::UdpSocket::bind(bind).ok()?; + sock.connect((peer, 9)).ok()?; + sock.local_addr().ok().map(|a| a.ip()) +} + +/// Is `name` a link fast enough that the AP path beats a P2P group? +/// +/// The interesting exclusion is **Bluetooth PAN**: the phone and laptop can be +/// on a PAN at the same time as Wi-Fi, and a PAN route shares no subnet with +/// the AP while being far too slow to treat as "already on the LAN". Cellular +/// and VPN/tunnel links are excluded for the same reason. +/// +/// `if_addrs` does not report interface *type*, so this is the platform- +/// specific part. On Linux `/sys/class/net//phy80211` positively identifies +/// 802.11, and everything not on the denylist is assumed to be a wired NIC. +/// +/// TODO(windows): replace the denylist with the real thing — +/// `GetAdaptersAddresses` reports `IfType`, so accept `IF_TYPE_ETHERNET_CSMACD` +/// and `IF_TYPE_IEEE80211` and reject the rest. `netdev` would also expose it +/// cross-platform if a dependency is preferable to the cfg split. +fn is_fast_lan_iface(name: &str) -> bool { + // Slow or virtual links, by conventional naming. Deliberately a denylist: + // a misnamed fast link only costs us a pointless P2P group, while a missed + // PAN would silently route a big transfer over Bluetooth. + const SLOW: [&str; 6] = ["bnep", "ppp", "wwan", "rmnet", "tun", "tap"]; + if SLOW.iter().any(|p| name.starts_with(p)) { + return false; + } + // The P2P interface is Wi-Fi too — it must never count as "the LAN we are + // already on", or joining would look unnecessary from inside the group. + if name.starts_with("p2p") { + return false; + } + true +} + /// Hook target (set in the worker): the phone offered a P2P group. If files are /// pending, switch onto it so the heartbeat pulls them over the fast link. pub(crate) fn on_wifi_direct_offer(app: AppHandle, ssid: String, pass: String) { @@ -112,6 +274,20 @@ pub(crate) fn on_wifi_direct_offer(app: AppHandle, ssid: String, pass: String) { if !pending || wd_active() { return; } + // Already on the same LAN as the phone? Then the AP path already reaches + // it, and a P2P group would buy little while costing BOTH devices their AP + // association — one radio each, so that is unavoidable. Observed: a + // 65-file share on a shared network switched networks purely because one + // file happened to exceed the size trigger. + if let Some(peer_ip) = crate::lan::last_good_peer_ip() { + if peer_on_same_fast_lan(peer_ip) { + tracing::info!( + %peer_ip, + "Wi-Fi Direct offer ignored: peer is on our LAN already" + ); + return; + } + } tokio::spawn(async move { let saved = current_wifi().await; tracing::info!(?saved, %ssid, "Wi-Fi Direct: joining group for fast pull"); @@ -127,12 +303,43 @@ pub(crate) fn on_wifi_direct_offer(app: AppHandle, ssid: String, pass: String) { n.notify_one(); // pull now over the GO } // Watchdog: never strand the laptop on the GO (failed pull / lost link). + // + // Polls for STALL rather than sleeping out a fixed deadline. A flat 60 s + // from join force-restored in the middle of any batch that legitimately + // took longer — observed cutting a 65-file share at file 26, costing the + // one Wi-Fi disconnect/reconnect that survived the idle-grace fix. What + // must be caught is a pull making no progress, which is precisely what + // `file_pull_active()` already answers (queued AND progressing). let app2 = app.clone(); tokio::spawn(async move { - tokio::time::sleep(Duration::from_secs(60)).await; - if wd_active() { - tracing::warn!("Wi-Fi Direct: watchdog timeout → force-restore Wi-Fi"); - restore_wifi(&app2).await; + let joined_at = tokio::time::Instant::now(); + loop { + tokio::time::sleep(Duration::from_secs(5)).await; + if !wd_active() { + return; // restored elsewhere (idle grace, or a new join) + } + // With nothing queued there is nothing to be stuck on — the + // idle-grace path owns that decision, and stepping on it here + // is what cut a healthy batch short. + if !crate::lan::files_queued() { + continue; + } + // Files ARE queued: measure real progress. `queue_progress_age` + // is the time since one last COMPLETED, which survives the + // queue oscillating empty→full under a paced sender. + let stalled = crate::lan::queue_progress_age() + .map(|age| age >= WD_STALL_TIMEOUT) + // Nothing has ever completed — fall back to time on the + // group so a join that never delivers still gets unstuck. + .unwrap_or(joined_at.elapsed() >= WD_STALL_TIMEOUT); + if stalled { + tracing::warn!( + "Wi-Fi Direct: queued pull made no progress for {}s → force-restore Wi-Fi", + WD_STALL_TIMEOUT.as_secs() + ); + restore_wifi(&app2).await; + return; + } } }); }); diff --git a/linux/ui-tauri/src-tauri/src/lib.rs b/linux/ui-tauri/src-tauri/src/lib.rs index 2625d2b..b820d38 100644 --- a/linux/ui-tauri/src-tauri/src/lib.rs +++ b/linux/ui-tauri/src-tauri/src/lib.rs @@ -54,7 +54,10 @@ mod lan_state; mod live_activity; mod media_remote; mod mirror; +mod arbiter; mod mirror_inject; +mod peer_cache; +mod peer_handoff; mod mirror_window; mod notes; mod notifications; @@ -117,6 +120,20 @@ pub(crate) static SYNC_NUDGE: std::sync::OnceLock> = pub(crate) static BLE_RETRY_NUDGE: std::sync::OnceLock> = std::sync::OnceLock::new(); +/// The BLE session's generic sealed-frame writer, published so command +/// handlers outside the BLE loop can send a frame to the CURRENTLY CONNECTED +/// peer. +/// +/// Deliberately "the connected peer", not an arbitrary one: it is a handle on +/// the live session's cipher state. That is exactly what +/// `PeerHandoff.RELEASE` needs — at the moment a switch is confirmed the live +/// link is still the peer being displaced (we have not connected to the +/// replacement yet), so this reaches the right device. If that ordering ever +/// changes, the RELEASE send in cmd_pairing has to change with it. +pub(crate) static BLE_SEALED_WRITER: std::sync::OnceLock< + Arc>>, +> = std::sync::OnceLock::new(); + /// Token of a phone-shared clipboard image waiting to be pulled over LAN. /// Set by the BLE image-offer consumer (which also nudges the heartbeat), /// added to the next bulk-sync request, and cleared once the LAN fetch @@ -365,6 +382,9 @@ pub fn run() { pairing::pair_decision, pairing::forget_peer, pairing::forget_all, + pairing::switch_peer, + pairing::cancel_switch, + pairing::activate_peer, earbuds::refresh_local_earbuds, earbuds::open_bluetooth_settings, earbuds::scan_bluetooth_devices, diff --git a/linux/ui-tauri/src-tauri/src/mirror_inject.rs b/linux/ui-tauri/src-tauri/src/mirror_inject.rs index c8cf418..8fa688f 100644 --- a/linux/ui-tauri/src-tauri/src/mirror_inject.rs +++ b/linux/ui-tauri/src-tauri/src/mirror_inject.rs @@ -80,9 +80,7 @@ const WIRELESS_PORT: u16 = 5555; static LAST_ADB_PORT: Mutex> = Mutex::new(None); fn adb_port_path() -> Option { - let mut p = std::path::PathBuf::from(std::env::var_os("HOME")?); - p.push(".cache/vortex/last_adb_port"); - Some(p) + crate::peer_cache::peer_file("last_adb_port") } /// Note the port a network transport is attached on, in memory and on disk. diff --git a/linux/ui-tauri/src-tauri/src/notes.rs b/linux/ui-tauri/src-tauri/src/notes.rs index d0b1193..907a53a 100644 --- a/linux/ui-tauri/src-tauri/src/notes.rs +++ b/linux/ui-tauri/src-tauri/src/notes.rs @@ -424,7 +424,7 @@ async fn send_full(writer: &Arc>> pub(crate) fn spawn_sync( app: AppHandle, writer: Arc>>, -) -> tokio::sync::mpsc::UnboundedSender<(u8, Vec)> { +) -> tokio::sync::mpsc::UnboundedSender<([u8; 32], u8, Vec)> { let notify = Arc::new(tokio::sync::Notify::new()); let _ = NOTES_DIRTY.set(notify.clone()); @@ -440,10 +440,12 @@ pub(crate) fn spawn_sync( }); } - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<(u8, Vec)>(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<([u8; 32], u8, Vec)>(); tokio::spawn(async move { let mut asm = Assembler::default(); - while let Some((ty, payload)) = rx.recv().await { + // Notes are one shared list across devices, so WHICH peer sent a + // chunk does not change the merge — ignore the identity here. + while let Some((_peer_pub, ty, payload)) = rx.recv().await { if ty != NOTES_FRAME { continue; // the raw channel is generic — ignore other features } diff --git a/linux/ui-tauri/src-tauri/src/pairing.rs b/linux/ui-tauri/src-tauri/src/pairing.rs index 7adbb63..c3e680c 100644 --- a/linux/ui-tauri/src-tauri/src/pairing.rs +++ b/linux/ui-tauri/src-tauri/src/pairing.rs @@ -237,3 +237,21 @@ pub fn forget_peer(peer_static_pub: String, state: State<'_, CmdChannel>) -> Res pub fn forget_all(state: State<'_, CmdChannel>) -> Result<(), String> { state.0.send(UiCmd::ForgetAll).map_err(|e| e.to_string()) } + +#[tauri::command] +pub fn switch_peer(state: State<'_, CmdChannel>) -> Result<(), String> { + state.0.send(UiCmd::SwitchPeer).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn cancel_switch(state: State<'_, CmdChannel>) -> Result<(), String> { + state.0.send(UiCmd::CancelSwitch).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn activate_peer(peer_static_pub: String, state: State<'_, CmdChannel>) -> Result<(), String> { + state + .0 + .send(UiCmd::ActivatePeer(peer_static_pub)) + .map_err(|e| e.to_string()) +} diff --git a/linux/ui-tauri/src-tauri/src/peer_cache.rs b/linux/ui-tauri/src-tauri/src/peer_cache.rs new file mode 100644 index 0000000..798b4c1 --- /dev/null +++ b/linux/ui-tauri/src-tauri/src/peer_cache.rs @@ -0,0 +1,113 @@ +//! Per-peer cache namespacing. +//! +//! Phone-specific caches used to live directly in `~/.cache/vortex/` +//! (`sms.json`, `contacts.json`, `call_log.json`, …). With one trusted phone +//! that was fine; with two it is silent data corruption — each phone's sync +//! overwrites the other's file, so the SMS page shows whichever phone synced +//! last. This module moves them under a per-peer directory: +//! +//! ```text +//! ~/.cache/vortex/peers//sms.json +//! ``` +//! +//! **Why the public key and not the peer's name.** The display name arrives +//! from the peer's APPROVE payload — it is attacker-influenced (which is why +//! `sanitize_peer_name` exists), it can contain path separators, it collides +//! ("Laptop"), and it changes when the user renames the device. A public key +//! is stable, unique, and safe as a path component. +//! +//! Genuinely shared state stays global on purpose: notes/todos are one list +//! across all devices by design, and so is clipboard history. + +use std::path::PathBuf; + +/// `~/.cache/vortex` — the shared root (notes, clipboard, icons live here). +fn cache_root() -> Option { + let mut p = PathBuf::from(std::env::var_os("HOME")?); + p.push(".cache/vortex"); + Some(p) +} + +/// Directory for the active peer's caches, created if absent. +/// +/// "Active" comes from [`crate::arbiter`] — the single owner of that notion, +/// so the cache paths and the session logic can never disagree about which +/// phone's data is on screen. +/// +/// `None` when no peer is active — before the first pairing there is nothing +/// to cache, and every caller already treats `None` as "skip the cache". +pub(crate) fn peer_dir() -> Option { + let peer = crate::arbiter::active()?; + let mut p = cache_root()?; + p.push("peers"); + p.push(hex::encode(&peer[..8])); + if let Err(e) = std::fs::create_dir_all(&p) { + tracing::debug!("peer cache dir {}: {e}", p.display()); + return None; + } + // 0700 explicitly, on the peer dir AND the `peers/` parent. + // `create_dir_all` applies the umask, which on most desktops yields 0755 — + // and these directories hold SMS bodies and the full contact list. The + // `~/.cache/vortex` root is already 0700 so nothing was actually exposed, + // but relying on an ancestor's mode is a fragile way to protect this. + restrict_to_owner(&p); + if let Some(parent) = p.parent() { + restrict_to_owner(parent); + } + Some(p) +} + +/// Best-effort `chmod 0700`. A failure is not fatal — the 0700 cache root +/// still shields the contents — so we log and carry on. +fn restrict_to_owner(dir: &std::path::Path) { + use std::os::unix::fs::PermissionsExt; + match std::fs::metadata(dir) { + Ok(md) if md.permissions().mode() & 0o777 != 0o700 => { + let mut perms = md.permissions(); + perms.set_mode(0o700); + if let Err(e) = std::fs::set_permissions(dir, perms) { + tracing::debug!("chmod 0700 {}: {e}", dir.display()); + } + } + _ => {} + } +} + +/// Path to `name` inside the active peer's directory, migrating a pre-existing +/// global file on first use. +/// +/// The migration is safe precisely because the old layout could only ever hold +/// **one** phone's data: whatever is in the legacy path belongs to the single +/// peer that wrote it, which is the peer we are keying under now. It runs once +/// per file — after the rename the legacy path is gone — and a failed rename +/// just means the cache starts empty and refills on the next sync. +pub(crate) fn peer_file(name: &str) -> Option { + let dir = peer_dir()?; + let new = dir.join(name); + if !new.exists() { + if let Some(legacy) = cache_root().map(|r| r.join(name)) { + if legacy.is_file() { + match std::fs::rename(&legacy, &new) { + Ok(()) => tracing::info!("migrated {} into per-peer cache", name), + Err(e) => tracing::debug!("migrate {name}: {e} (starting empty)"), + } + } + } + } + Some(new) +} + +/// Delete the active peer's whole cache directory. Used by `ForgetPeer` so a +/// forgotten phone leaves no SMS/contacts/call-log behind. +pub(crate) fn remove_peer_dir(peer_pub: &[u8; 32]) { + let Some(mut p) = cache_root() else { return }; + p.push("peers"); + p.push(hex::encode(&peer_pub[..8])); + if !p.exists() { + return; + } + match std::fs::remove_dir_all(&p) { + Ok(()) => tracing::info!("removed per-peer cache for {}", hex::encode(&peer_pub[..4])), + Err(e) => tracing::warn!("could not remove {}: {e}", p.display()), + } +} diff --git a/linux/ui-tauri/src-tauri/src/peer_handoff.rs b/linux/ui-tauri/src-tauri/src/peer_handoff.rs new file mode 100644 index 0000000..5cd077f --- /dev/null +++ b/linux/ui-tauri/src-tauri/src/peer_handoff.rs @@ -0,0 +1,97 @@ +//! Inbound `PEER_HANDOFF` handling, and the fan-out for the generic +//! additive-frame channel. +//! +//! The BLE listener forwards every allowed frame that has no dedicated handler +//! down ONE generic channel as `(peer_pub, frame_ty, payload)`. That channel is +//! single-consumer, and notes/todos already owned it — so a second additive +//! feature had nowhere to listen. Rather than add yet another parameter to +//! `run_listener` (which the channel's own doc comment exists to avoid), this +//! module owns the channel, handles the frames it cares about, and forwards +//! everything else on to notes. +//! +//! What arriving `RELEASE` means: the phone has made a *different* laptop its +//! active peer. Ownership on our side has to follow, or the UI keeps claiming +//! "Connected" to a phone that has moved on — the stale-card problem in design +//! doc §D4. We learn it immediately instead of on next contact. + +use std::sync::Arc; + +use tauri::AppHandle; +use vortex_l3_daemon::core::ble::frame::{sub, ty}; +use vortex_l3_daemon::core::storage::peers::PeerStore; + +/// Own the generic additive-frame channel: handle `PEER_HANDOFF`, forward the +/// rest to `notes_tx`. Returns the sender the BLE listener writes into. +pub(crate) fn spawn_dispatcher( + app: AppHandle, + peer_store: Arc, + notes_tx: tokio::sync::mpsc::UnboundedSender<([u8; 32], u8, Vec)>, +) -> tokio::sync::mpsc::UnboundedSender<([u8; 32], u8, Vec)> { + let (tx, mut rx) = + tokio::sync::mpsc::unbounded_channel::<([u8; 32], u8, Vec)>(); + tokio::spawn(async move { + while let Some((peer_pub, frame_ty, payload)) = rx.recv().await { + if frame_ty != ty::PEER_HANDOFF { + // Not ours — pass it along. A closed notes channel means the + // app is shutting down; stop rather than spin. + if notes_tx.send((peer_pub, frame_ty, payload)).is_err() { + break; + } + continue; + } + handle(&app, &peer_store, peer_pub, &payload).await; + } + }); + tx +} + +async fn handle( + app: &AppHandle, + peer_store: &Arc, + peer_pub: [u8; 32], + payload: &[u8], +) { + // The kind is the first payload byte (the sender's writer only carries a + // frame type). An empty payload is malformed: drop it rather than default + // to a kind and act on a guess. + let Some((&kind, rest)) = payload.split_first() else { + tracing::warn!("PEER_HANDOFF with empty payload; ignoring"); + return; + }; + match kind { + sub::HANDOFF_RELEASE => { + // Peer-supplied text, so sanitise before it can reach the UI or + // the logs — same rule the pairing name path follows. + let successor = String::from_utf8_lossy(rest).to_string(); + let successor = + vortex_l3_daemon::core::pairing::handshake::sanitize_peer_name(&successor); + tracing::info!( + peer = %hex::encode(&peer_pub[..4]), + successor = %if successor.is_empty() { "".into() } else { successor.clone() }, + "peer released us — dropping active ownership" + ); + // Only ownership is dropped, NOT trust: the phone still trusts us + // and may well come back. Forget is a separate, user-driven act. + crate::arbiter::release(&peer_pub); + // Blank the pages that were showing this phone's data, then re-emit + // so the UI's `active` flag clears on its card. + crate::cmd_pairing::purge_peer_cache(app); + crate::emit_peers(app, peer_store.clone()).await; + } + sub::HANDOFF_BUSY => { + // Nothing sends this yet; log rather than drop silently so an + // unexpected one is visible in the field. + tracing::info!( + peer = %hex::encode(&peer_pub[..4]), + "PEER_HANDOFF BUSY — another peer holds that phone" + ); + } + sub::HANDOFF_CLAIM => { + tracing::info!( + peer = %hex::encode(&peer_pub[..4]), + "PEER_HANDOFF CLAIM — no handler yet" + ); + } + other => tracing::warn!("PEER_HANDOFF unknown kind 0x{other:02x}; ignoring"), + } +} diff --git a/linux/ui-tauri/src-tauri/src/sms.rs b/linux/ui-tauri/src-tauri/src/sms.rs index 40c660e..4c44844 100644 --- a/linux/ui-tauri/src-tauri/src/sms.rs +++ b/linux/ui-tauri/src-tauri/src/sms.rs @@ -12,9 +12,7 @@ use vortex_l3_daemon::core::sms::{SmsAssembler, SmsMessage}; /// `~/.cache/vortex/sms.json` — survives a daemon restart so the page shows the /// last-known messages instantly while a fresh sync arrives. fn cache_path() -> Option { - let mut p = PathBuf::from(std::env::var_os("HOME")?); - p.push(".cache/vortex/sms.json"); - Some(p) + crate::peer_cache::peer_file("sms.json") } /// Spawn the SMS consumer; returns the sender the BLE listener feeds @@ -191,15 +189,11 @@ pub(crate) fn get_sms() -> Vec { // what's missing — reading one tiny file instead of parsing the store. fn history_path() -> Option { - let mut p = PathBuf::from(std::env::var_os("HOME")?); - p.push(".cache/vortex/sms_history.json"); - Some(p) + crate::peer_cache::peer_file("sms_history.json") } fn history_since_path() -> Option { - let mut p = PathBuf::from(std::env::var_os("HOME")?); - p.push(".cache/vortex/sms_history.since"); - Some(p) + crate::peer_cache::peer_file("sms_history.since") } /// The history watermark: newest message date we've synced, 0 = nothing yet diff --git a/linux/ui-tauri/src-tauri/src/worker.rs b/linux/ui-tauri/src-tauri/src/worker.rs index bb4e3c7..3912c58 100644 --- a/linux/ui-tauri/src-tauri/src/worker.rs +++ b/linux/ui-tauri/src-tauri/src/worker.rs @@ -203,7 +203,17 @@ pub(crate) fn run_worker(app: AppHandle, cmd_rx: Receiver) { } }; emit_peers(&app, peer_store.clone()).await; - let _have_trust = !peer_store.list().unwrap_or_default().is_empty(); + let trusted = peer_store.list().unwrap_or_default(); + let _have_trust = !trusted.is_empty(); + // Point the phone-specific caches at the trusted peer before any + // session exists, so the SMS/contacts/call-log pages render from cache + // at startup exactly as they did when those files were global. Only + // when there is exactly one peer: with several, "which phone's data" + // has no answer until a session picks one (BLE IK sets it), and + // guessing would show the wrong phone's messages. + if let [only] = trusted.as_slice() { + crate::arbiter::claim(&only.peer_static_pub); + } // BLE adapter. // BlueZ is very often not ready yet at this point. The autostart entry @@ -422,7 +432,16 @@ pub(crate) fn run_worker(app: AppHandle, cmd_rx: Receiver) { // into. All merge/protocol logic lives in notes.rs. let ble_sealed_writer: Arc>> = Arc::new(tokio::sync::Mutex::new(None)); + let _ = crate::BLE_SEALED_WRITER.set(ble_sealed_writer.clone()); let ble_notes_tx = crate::notes::spawn_sync(app.clone(), ble_sealed_writer.clone()); + // The generic additive-frame channel is single-consumer and notes used to + // own it. Put the peer-handoff dispatcher in front: it takes the frames it + // handles and forwards the rest to notes unchanged. + let ble_raw_tx = crate::peer_handoff::spawn_dispatcher( + app.clone(), + peer_store.clone(), + ble_notes_tx, + ); crate::notes::spawn_reminders(); // desktop due-date reminders // BLE app-icon channel: the listener forwards ICON chunks here; a @@ -527,7 +546,7 @@ pub(crate) fn run_worker(app: AppHandle, cmd_rx: Receiver) { let ble_clipboard_image_tx = ble_clipboard_image_tx.clone(); let ble_clipboard_offer_tx = ble_clipboard_offer_tx.clone(); let ble_handoff_tx = ble_handoff_tx.clone(); - let ble_notes_tx = ble_notes_tx.clone(); + let ble_raw_tx = ble_raw_tx.clone(); let ble_notif_writer = ble_notif_writer.clone(); let ble_clipboard_writer = ble_clipboard_writer.clone(); let ble_clipboard_image_writer = ble_clipboard_image_writer.clone(); @@ -555,7 +574,7 @@ pub(crate) fn run_worker(app: AppHandle, cmd_rx: Receiver) { ble_clipboard_image_tx, ble_clipboard_offer_tx, ble_handoff_tx, - ble_notes_tx, + ble_raw_tx, ble_notif_writer, ble_clipboard_writer, ble_clipboard_image_writer, @@ -699,6 +718,11 @@ pub(crate) fn run_worker(app: AppHandle, cmd_rx: Receiver) { UiCmd::Pair(addr_str) => cmd_pairing::pair(&ctx, addr_str, &mut active_scan).await, UiCmd::ForgetPeer(hex_str) => cmd_pairing::forget_peer(&ctx, hex_str).await, UiCmd::ForgetAll => cmd_pairing::forget_all(&ctx).await, + UiCmd::SwitchPeer => cmd_pairing::switch_peer(&ctx), + UiCmd::CancelSwitch => cmd_pairing::cancel_switch(&ctx).await, + UiCmd::ActivatePeer(hex_str) => { + cmd_pairing::activate_peer(&ctx, hex_str).await + } UiCmd::RefreshState => cmd_earbuds::refresh_state(&ctx).await, UiCmd::RefreshLocalEarbuds => cmd_earbuds::refresh_local_earbuds(&ctx).await, UiCmd::RequestEarbudsSwitch { peer_static_pub, mac } => { diff --git a/linux/ui-tauri/src/composables/useHome.ts b/linux/ui-tauri/src/composables/useHome.ts index 6c2f8cb..6788222 100644 --- a/linux/ui-tauri/src/composables/useHome.ts +++ b/linux/ui-tauri/src/composables/useHome.ts @@ -4,12 +4,14 @@ import type { UnlistenFn } from "@tauri-apps/api/event"; import { Battery, BatteryLow, BatteryMedium, BatteryFull, BatteryCharging } from "lucide-vue-next"; import { startScan, startPair, forgetPeer, refreshState, refreshLocalEarbuds, + switchPeer, cancelSwitch, activatePeer, requestEarbudsSwitch, sendEarbudsClaim, getSavedEarbuds, onSwitchState, type SwitchState, scanBluetoothDevices, saveEarbuds, clearEarbuds, onScanResult, onScanDone, onPairingStarted, onPairingResult, onPairingSas, + onSwitchScanning, onSwitchCandidates, pairDecision, onLocalEarbuds, onBusy, type ScanHit, type TrustedPeer, type PairingResultEvent, type PeerState, - type EarbudsSnapshot, type BluetoothDeviceRow, + type EarbudsSnapshot, type BluetoothDeviceRow, type SwitchCandidate, } from "@/lib/bridge"; import { initSmartSwitch } from "@/lib/smartSwitch"; import { initNotifMirror } from "@/lib/notifMirror"; @@ -431,6 +433,39 @@ export function onCardPressEnd() { } } +// ---- Switch to another already-paired phone ---- +// +// Distinct from `isSwitching` / `switchState` above, which are the EARBUDS +// handoff. This is the device switch: keep the current phone connected while +// looking for another trusted one, and only hand ownership over once a +// replacement is in hand (design doc §D3). + +/** True while the backend is scanning for other trusted phones. */ +export const peerSwitchScanning = ref(false); +/** Candidates from the last scan. Empty + not scanning = nothing found. */ +export const peerSwitchCandidates = ref([]); +/** Set once a scan completes so the UI can say "none found" rather than + * silently returning to the normal card. */ +export const peerSwitchNoneFound = ref(false); + +export async function startPeerSwitch() { + peerSwitchNoneFound.value = false; + peerSwitchCandidates.value = []; + await switchPeer(); +} + +export async function abortPeerSwitch() { + peerSwitchNoneFound.value = false; + peerSwitchCandidates.value = []; + await cancelSwitch(); +} + +export async function choosePeer(peerStaticPub: string) { + peerSwitchCandidates.value = []; + peerSwitchNoneFound.value = false; + await activatePeer(peerStaticPub); +} + // ---- Continuous BLE scan while no trust or pair-modal open ---- export async function runScanLoop() { if (scanLoopActive) return; @@ -559,6 +594,17 @@ export async function initHome() { scanHits.value.push(hit); })); unlisten.push(await onScanDone(() => (scanning.value = false))); + unlisten.push(await onSwitchScanning(on => { + peerSwitchScanning.value = on; + if (on) peerSwitchNoneFound.value = false; + })); + unlisten.push(await onSwitchCandidates(list => { + peerSwitchCandidates.value = list; + // An empty list after a scan means "nothing else in range" — the + // backend also emits empty to CLEAR the picker once a peer is + // adopted, which is why this only latches while not scanning. + peerSwitchNoneFound.value = list.length === 0 && !peerSwitchScanning.value; + })); unlisten.push(await onPairingStarted(e => { pairingPeer.value = e.peer_addr; pairingResult.value = null; diff --git a/linux/ui-tauri/src/lib/bridge.ts b/linux/ui-tauri/src/lib/bridge.ts index 273db24..5ffd227 100644 --- a/linux/ui-tauri/src/lib/bridge.ts +++ b/linux/ui-tauri/src/lib/bridge.ts @@ -17,6 +17,17 @@ export interface TrustedPeer { peer_static_pub: string; paired_at: number; peer_name?: string | null; + /** True for the peer that currently owns the session (see `arbiter` on the + * backend). With several trusted phones, "remembered" and "the one whose + * data is on screen" are different things. */ + active: boolean; +} + +/** A trusted peer found on air during a switch scan. */ +export interface SwitchCandidate { + peer_static_pub: string; + name?: string | null; + rssi: number; } /** @@ -67,6 +78,26 @@ export async function forgetPeer(peerStaticPub: string): Promise { await invoke("forget_peer", { peerStaticPub }); } +/** + * "Switch device": keep the current phone connected and look for another + * already-trusted one. Not a disconnect — the backend holds the active link + * for the whole scan, so a cancelled or fruitless switch leaves you exactly + * where you were. + */ +export async function switchPeer(): Promise { + await invoke("switch_peer"); +} + +export async function cancelSwitch(): Promise { + await invoke("cancel_switch"); +} + +/** Adopt this trusted peer as the active one (the user's pick, or the sole + * candidate found). */ +export async function activatePeer(peerStaticPub: string): Promise { + await invoke("activate_peer", { peerStaticPub }); +} + export async function forgetAll(): Promise { await invoke("forget_all"); } @@ -208,6 +239,16 @@ export function onScanDone(cb: () => void): Promise { return listen("vortex:scan_done", () => cb()); } +export function onSwitchScanning(cb: (scanning: boolean) => void): Promise { + return listen("vortex:switch_scanning", e => cb(e.payload)); +} + +export function onSwitchCandidates( + cb: (candidates: SwitchCandidate[]) => void, +): Promise { + return listen("vortex:switch_candidates", e => cb(e.payload)); +} + export function onPairingStarted(cb: (e: PairingStartedEvent) => void): Promise { return listen("vortex:pairing_started", e => cb(e.payload)); } diff --git a/linux/ui-tauri/src/lib/locales/en.json b/linux/ui-tauri/src/lib/locales/en.json index fd8a01c..57026b8 100644 --- a/linux/ui-tauri/src/lib/locales/en.json +++ b/linux/ui-tauri/src/lib/locales/en.json @@ -68,7 +68,12 @@ "offline": "Offline", "forget_title": "Forget device", "forget_body": "Stop trusting {name}? You'll need to re-pair to connect again.", - "forget_confirm": "Forget" + "forget_confirm": "Forget", + "switch_tip": "Switch to another paired phone", + "switch_scanning": "Looking for your other phones…", + "switch_pick": "Switch to which phone?", + "switch_none": "No other paired phone nearby.", + "switch_cancel": "Cancel" }, "discover": { "looking": "Looking for nearby devices…", diff --git a/linux/ui-tauri/src/lib/locales/ru.json b/linux/ui-tauri/src/lib/locales/ru.json index 7875cd5..3db8c80 100644 --- a/linux/ui-tauri/src/lib/locales/ru.json +++ b/linux/ui-tauri/src/lib/locales/ru.json @@ -68,7 +68,12 @@ "offline": "Не в сети", "forget_title": "Забыть устройство", "forget_body": "Перестать доверять {name}? Чтобы вновь подключиться, потребуется повторное сопряжение.", - "forget_confirm": "Забыть" + "forget_confirm": "Забыть", + "switch_tip": "Переключиться на другой телефон", + "switch_scanning": "Поиск других ваших телефонов…", + "switch_pick": "На какой телефон переключиться?", + "switch_none": "Рядом нет другого сопряжённого телефона.", + "switch_cancel": "Отмена" }, "discover": { "looking": "Поиск устройств поблизости…", diff --git a/linux/ui-tauri/src/lib/locales/uz.json b/linux/ui-tauri/src/lib/locales/uz.json index 2f08c9e..ac0d4d4 100644 --- a/linux/ui-tauri/src/lib/locales/uz.json +++ b/linux/ui-tauri/src/lib/locales/uz.json @@ -68,7 +68,12 @@ "offline": "Oflayn", "forget_title": "Qurilmani unutish", "forget_body": "{name} bilan aloqa uziladi. Qayta ulanish uchun yana pairing qilinadi.", - "forget_confirm": "Unutish" + "forget_confirm": "Unutish", + "switch_tip": "Boshqa telefonga o'tish", + "switch_scanning": "Boshqa telefonlaringiz qidirilmoqda…", + "switch_pick": "Qaysi telefonga o'tamiz?", + "switch_none": "Yaqinda boshqa ulangan telefon yo'q.", + "switch_cancel": "Bekor qilish" }, "discover": { "looking": "Yaqindagi qurilmalar qidirilmoqda…", diff --git a/linux/ui-tauri/src/pages/home/Devices.vue b/linux/ui-tauri/src/pages/home/Devices.vue index b93ef04..f7287a6 100644 --- a/linux/ui-tauri/src/pages/home/Devices.vue +++ b/linux/ui-tauri/src/pages/home/Devices.vue @@ -16,6 +16,7 @@ import { Plus, BellRing, SwitchCamera, + TabletSmartphone, } from "lucide-vue-next"; import { activeEarbuds, @@ -33,7 +34,14 @@ import { primaryPeer, primaryPeerState, startMirror, + peerSwitchScanning, + peerSwitchCandidates, + peerSwitchNoneFound, + startPeerSwitch, + abortPeerSwitch, + choosePeer, } from "@/composables/useHome"; +import { peers } from "@/lib/connectionStore"; const { t } = useI18n(); @@ -153,6 +161,19 @@ const earbudsStatus = computed(() => { > + +
{
Charging + +
+
+
{{ t("peers.switch_pick") }}
+ + +
+
+
+
{{ t("peers.switch_none") }}
+
+
+
+
{{ t("peers.switch_scanning") }}
+ +