From 3477b226628dd20fe1760609936a1e94576be28e Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Tue, 25 Aug 2026 18:52:08 +0200 Subject: [PATCH 01/21] fix(pairing): drop the BT bond when a peer is forgotten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forgetting a peer left the Bluetooth bond in place on both sides, so the next pairing attempt fought a bond only one side still held: the link is torn down during encryption, `ServicesResolved` never arrives, and pairing dies with `timeout: service discovery`. That is the "retry several times until it works" symptom. Reproduced live 2026-08-25 — the phone held a `[DUAL]` bond for a laptop whose own bond had been dropped, and pairing failed three times in a row until the bond was cleared by hand. Vortex never creates these bonds itself (Linux deliberately skips `Device::pair()` — see the 2026-06-02 note in pairing.rs), which is exactly why nothing ever cleaned them up: they arrive via the desktop's Bluetooth panel or an older build, and Android hides profile-less LE bonds from Settings, so the user cannot clear one by hand either. Android — the side that actually holds a bond: * PeerStore gains load/savePeerBtAddr (defaulted, so the no-op stores are unaffected). BondCleaner needs an address and the phone stored none, which is why BondCleaner was reachable only from the DEBUG `remove_bond` intent and never from Forget. * Recorded at pairing from the central's address, and backfilled on every successful IK so pairings predating this commit get cleaned without re-pairing. * onForgetPeerClicked / onForgetAllClicked now drop the bond *before* the store entry goes away, while the address is still on file. Linux — no bond to drop, but the peer's BlueZ device object lingers, and its stale RPA is what feeds the RPA-churn connect wedge on the next pairing. Recorded per peer in PEER_BLE_ADDRS and evicted on forget. The address is recorded only *after* IK succeeds, on both sides. Before IK we would only be trusting a presence-token match, and acting on that could evict a stranger's device object or clear an unrelated bond. Co-Authored-By: Claude Opus 5 --- .../a3/core/pairing/ReconnectOrchestrator.kt | 12 ++++++ .../core/storage/EncryptedPrefsPeerStore.kt | 27 +++++++++++++ .../com/vortex/a3/ui/MainActivityPairing.kt | 24 ++++++++++++ linux/ui-tauri/src-tauri/src/ble.rs | 39 ++++++++++++++++++- linux/ui-tauri/src-tauri/src/cmd_pairing.rs | 29 ++++++++++++++ 5 files changed, 130 insertions(+), 1 deletion(-) 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/storage/EncryptedPrefsPeerStore.kt b/android/app/src/main/java/com/vortex/a3/core/storage/EncryptedPrefsPeerStore.kt index 9fc71c2..e6cd4a7 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. */ @@ -192,9 +211,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/ui/MainActivityPairing.kt b/android/app/src/main/java/com/vortex/a3/ui/MainActivityPairing.kt index db6a7cd..2432520 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 @@ -187,6 +192,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 +219,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/linux/ui-tauri/src-tauri/src/ble.rs b/linux/ui-tauri/src-tauri/src/ble.rs index f84fef9..088c063 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); @@ -719,7 +753,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)"), @@ -964,6 +998,9 @@ 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). + remember_peer_addr(&peer.peer_static_pub, client.address); 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/cmd_pairing.rs b/linux/ui-tauri/src-tauri/src/cmd_pairing.rs index c9bc61a..57da5b6 100644 --- a/linux/ui-tauri/src-tauri/src/cmd_pairing.rs +++ b/linux/ui-tauri/src-tauri/src/cmd_pairing.rs @@ -162,6 +162,17 @@ 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. purge_peer_cache(&ctx.app); emit_peers(&ctx.app, ctx.peer_store.clone()).await; @@ -197,6 +208,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 +235,12 @@ pub(crate) async fn forget_all(ctx: &WorkerCtx) { } }) .await; + // Same BlueZ cleanup as `forget_peer`, for every dropped 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; + } + } purge_peer_cache(&ctx.app); emit_peers(&ctx.app, ctx.peer_store.clone()).await; } From f56ab2f9ff70b5584be28ecc1a0d2826a4ea1bc0 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Tue, 25 Aug 2026 18:56:28 +0200 Subject: [PATCH 02/21] feat(cache): namespace phone-specific caches per peer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phone-specific caches lived directly in ~/.cache/vortex/ — sms.json, contacts.json, call_log.json, the history files, last_peer_ip, last_adb_port. 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 and Contacts pages show whichever phone synced last. That has to be fixed before a laptop can remember several phones. Caches move to: ~/.cache/vortex/peers//… Keyed on the public key, not the peer's name: the display name arrives from the peer's APPROVE payload, so it is attacker-influenced (hence the existing sanitize_peer_name), it can contain path separators, it collides ("Laptop"), and it changes when the device is renamed. A public key is stable, unique, and safe as a path component. Genuinely shared state deliberately stays global: notes/todos are one list across all devices by design, and so is clipboard history. Existing installs migrate on first use. The rename is safe precisely because the old layout could only ever hold ONE phone's data, so whatever sits in the legacy path belongs to the peer we now key under. It runs once per file, and a failed rename only means the cache refills on the next sync. Verified against real data: contacts.json, sms.json, a 1.1 MB sms_history.json, sms_history.since and last_peer_ip all moved intact. Both directories are chmod 0700 explicitly. create_dir_all applies the umask, which on most desktops yields 0755 — and these hold SMS bodies and the full contact list. The 0700 cache root meant nothing was actually exposed, but relying on an ancestor's mode to protect this is fragile. Forget now removes the peer's directory as well, so a forgotten phone leaves no messages or contacts behind. ACTIVE_PEER is the interim owner of "which peer is active": set from the single trusted peer at startup and refreshed whenever a BLE session completes IK. Deliberately only when exactly one peer exists — with several, "which phone's data" has no answer until a session picks one, and guessing would render the wrong phone's messages. The arbiter in the multi-peer design (docs/design/multi-peer.md §D4) takes this over. Co-Authored-By: Claude Opus 5 --- linux/ui-tauri/src-tauri/src/ble.rs | 4 +- linux/ui-tauri/src-tauri/src/call_log.rs | 12 +- linux/ui-tauri/src-tauri/src/cmd_pairing.rs | 8 +- linux/ui-tauri/src-tauri/src/contacts.rs | 4 +- linux/ui-tauri/src-tauri/src/lan.rs | 4 +- linux/ui-tauri/src-tauri/src/lib.rs | 1 + linux/ui-tauri/src-tauri/src/mirror_inject.rs | 4 +- linux/ui-tauri/src-tauri/src/peer_cache.rs | 143 ++++++++++++++++++ linux/ui-tauri/src-tauri/src/sms.rs | 12 +- linux/ui-tauri/src-tauri/src/worker.rs | 12 +- 10 files changed, 174 insertions(+), 30 deletions(-) create mode 100644 linux/ui-tauri/src-tauri/src/peer_cache.rs diff --git a/linux/ui-tauri/src-tauri/src/ble.rs b/linux/ui-tauri/src-tauri/src/ble.rs index 088c063..5a513b9 100644 --- a/linux/ui-tauri/src-tauri/src/ble.rs +++ b/linux/ui-tauri/src-tauri/src/ble.rs @@ -999,8 +999,10 @@ 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). + // 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::peer_cache::set_active_peer(&peer.peer_static_pub); 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/cmd_pairing.rs b/linux/ui-tauri/src-tauri/src/cmd_pairing.rs index 57da5b6..3794904 100644 --- a/linux/ui-tauri/src-tauri/src/cmd_pairing.rs +++ b/linux/ui-tauri/src-tauri/src/cmd_pairing.rs @@ -174,7 +174,11 @@ pub(crate) async fn forget_peer(ctx: &WorkerCtx, hex_str: String) { 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::peer_cache::clear_active_peer(&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 @@ -235,11 +239,13 @@ pub(crate) async fn forget_all(ctx: &WorkerCtx) { } }) .await; - // Same BlueZ cleanup as `forget_peer`, for every dropped peer. + // 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::peer_cache::clear_active_peer(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/lan.rs b/linux/ui-tauri/src-tauri/src/lan.rs index 2557d10..14e438a 100644 --- a/linux/ui-tauri/src-tauri/src/lan.rs +++ b/linux/ui-tauri/src-tauri/src/lan.rs @@ -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 diff --git a/linux/ui-tauri/src-tauri/src/lib.rs b/linux/ui-tauri/src-tauri/src/lib.rs index 2625d2b..5e592c9 100644 --- a/linux/ui-tauri/src-tauri/src/lib.rs +++ b/linux/ui-tauri/src-tauri/src/lib.rs @@ -55,6 +55,7 @@ mod live_activity; mod media_remote; mod mirror; mod mirror_inject; +mod peer_cache; mod mirror_window; mod notes; mod notifications; 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/peer_cache.rs b/linux/ui-tauri/src-tauri/src/peer_cache.rs new file mode 100644 index 0000000..574620f --- /dev/null +++ b/linux/ui-tauri/src-tauri/src/peer_cache.rs @@ -0,0 +1,143 @@ +//! 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; + +/// The peer whose data the phone-specific caches currently refer to. +/// +/// Interim owner of "which peer is active". The full arbiter (design doc +/// §D4 — separating *connected* from *active*) will take this over; until +/// then it is set from the single trusted peer at startup and refreshed +/// whenever a BLE session completes Noise IK. That reproduces today's +/// behaviour exactly for the single-peer case, which is every existing +/// install, while giving the cache paths a real peer to key on. +static ACTIVE_PEER: std::sync::Mutex> = std::sync::Mutex::new(None); + +/// Point the phone-specific caches at `peer_pub`. +pub(crate) fn set_active_peer(peer_pub: &[u8; 32]) { + if let Ok(mut g) = ACTIVE_PEER.lock() { + if g.as_ref() != Some(peer_pub) { + tracing::debug!(peer = %hex::encode(&peer_pub[..4]), "active peer for caches"); + } + *g = Some(*peer_pub); + } +} + +/// Forget the active peer if it is `peer_pub` (no-op for any other peer, so a +/// `ForgetPeer` for an inactive device cannot blank the active one's paths). +pub(crate) fn clear_active_peer(peer_pub: &[u8; 32]) { + if let Ok(mut g) = ACTIVE_PEER.lock() { + if g.as_ref() == Some(peer_pub) { + *g = None; + } + } +} + +fn active_peer() -> Option<[u8; 32]> { + ACTIVE_PEER.lock().ok().and_then(|g| *g) +} + +/// `~/.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. +/// +/// `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 = active_peer()?; + 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/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..1199de2 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::peer_cache::set_active_peer(&only.peer_static_pub); + } // BLE adapter. // BlueZ is very often not ready yet at this point. The autostart entry From 64fb21c958de5e17f632e1a5596d5d0c3cd63337 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Tue, 25 Aug 2026 19:18:46 +0200 Subject: [PATCH 03/21] feat(multi-peer): pair a second laptop, and split active from connected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of the multi-peer work (docs/design/multi-peer.md): the phone can now be offered to another laptop without forgetting the first, and the laptop tracks session *ownership* separately from transport links. Phone — "Pair another laptop": 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 at all. That was the single-peer trap. The window has to *preempt* presence rather than run beside it: Advertiser holds one advertising set and startWith refuses while another is active, and once trust exists VortexService owns the radio, GATT server and LAN listener (an Activity-local LanServer would race it for port 51820). So it stops the service, advertises pairable from the Activity, and hands the radio back on close. Bounded at 120 s — while the window is open the paired laptop cannot see this phone, and would read it as "away" for proximity auto-lock. Verified on device: service releases NSD -> activity takes port 51820 -> "advertise started: pairable" WITH trust on file, mDNS instance matching the BLE payload_8 per spec §5.4; Cancel restores trusted-presence and the service. The permission-grant callback routes to the window via pendingPairingWindow instead of falling through to startAdvertising(), which would have started presence — silently the opposite of what was asked. Protocol — PeerHandoff (payload oneof, field 30): RELEASE / BUSY / CLAIM, for agreeing which peer owns the single active session. Additive and gated on the existing capability_flags, so an older build ignores the unknown field. Nothing in AdvPayload, the advertising flags, the GATT UUIDs or the presence-token derivation changes — deliberately no wire break. BUSY exists because a peer that cannot tell refusal from packet loss retries in a tight loop against the phone's single GATT link. Laptop — the arbiter: Separates *connected* (a transport link exists; several may overlap, and briefly do during a handoff) from *active* (owns the mirrored state: notifications, clipboard, SMS/contacts/call-log, media; exactly one, ever). Without the split, connecting to a replacement before the old link finished dropping would give two phones ownership at once, both mirroring notifications into the same laptop. * claim() is idempotent for the current owner, so a reconnect is never mistaken for a competing peer; * force_activate() displaces and returns the displaced peer, so the caller can send it RELEASE; * note_disconnected() deliberately does NOT release ownership — BLE drops during RPA churn are routine and must not blank the UI's data source; * the switch window is bounded, because seeking on top of a live connection is the most expensive radio state there is. peer_cache now delegates to it rather than keeping a second notion of "active", so cache paths and session logic cannot disagree about which phone's data is on screen. Two unit tests pin the ownership rules. The switch half of the arbiter API is written but not yet called; the Switch button and candidate picker land next. Scoped allow(dead_code) with a note to remove it then — the ownership rules belong in one reviewed place with the tests that pin them, not added piecemeal. Co-Authored-By: Claude Opus 5 --- .../java/com/vortex/a3/ui/MainActivity.kt | 18 +- .../com/vortex/a3/ui/MainActivityPairing.kt | 101 ++++++++ .../src/main/java/com/vortex/a3/ui/Strings.kt | 9 + .../main/java/com/vortex/a3/ui/VortexRoot.kt | 6 + .../com/vortex/a3/ui/screens/HomeScreen.kt | 35 +++ linux/ui-tauri/src-tauri/src/arbiter.rs | 235 ++++++++++++++++++ linux/ui-tauri/src-tauri/src/ble.rs | 15 +- linux/ui-tauri/src-tauri/src/cmd_pairing.rs | 6 +- linux/ui-tauri/src-tauri/src/lib.rs | 1 + linux/ui-tauri/src-tauri/src/peer_cache.rs | 40 +-- linux/ui-tauri/src-tauri/src/worker.rs | 2 +- resume_session.sh | 1 + shared/proto/vortex.proto | 41 +++ 13 files changed, 470 insertions(+), 40 deletions(-) create mode 100644 linux/ui-tauri/src-tauri/src/arbiter.rs create mode 100644 resume_session.sh 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..8652d72 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) @@ -198,6 +208,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 +222,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()}") } @@ -421,6 +435,8 @@ class MainActivity : ComponentActivity() { /** Bundle the activity's callbacks for the root composable. */ private fun buildActions(): VortexActions = VortexActions( onForgetPeer = ::onForgetPeerClicked, + onAddPair = ::onAddPairClicked, + onCancelAddPair = ::endPairingWindow, 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 2432520..ec898d3 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 @@ -133,6 +133,107 @@ 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 + } +} + internal fun MainActivity.onApproveClicked(outcome: PairingOrchestrator.HandshakeOutcome) { val orch = pairingOrchestrator ?: return val frame = orch.buildLocalApprovalFrame( 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..8b8d2f2 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,9 @@ 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.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 +192,9 @@ 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.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 +332,9 @@ private val RU = mapOf( "peers.forget_title" to "Забыть устройство", "peers.forget_body" to "Перестать доверять %s? Чтобы вновь подключиться, потребуется повторное сопряжение.", "peers.forget_confirm" to "Забыть", + "peers.add_pair" 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..d5ddcf0 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 @@ -75,6 +75,10 @@ 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, val onOpenAutostart: () -> Unit, val onDismissAutostartHint: () -> Unit, val onRequestBatteryWhitelist: () -> Unit, @@ -257,6 +261,8 @@ fun VortexRoot( pickerState = ui.picker.collectAsState().value, switchState = EarbudsSwitchHolder.state.collectAsState().value, onForgetPeer = actions.onForgetPeer, + onAddPair = actions.onAddPair, + onCancelAddPair = actions.onCancelAddPair, onOpenAutostart = actions.onOpenAutostart, onDismissAutostartHint = actions.onDismissAutostartHint, onRequestBatteryWhitelist = actions.onRequestBatteryWhitelist, 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..cfd6d64 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 @@ -87,6 +87,8 @@ fun HomeScreen( pickerState: PickerState, switchState: SwitchState, onForgetPeer: (TrustedPeer) -> Unit, + onAddPair: () -> Unit, + onCancelAddPair: () -> Unit, onOpenAutostart: () -> Unit, onDismissAutostartHint: () -> Unit, onRequestBatteryWhitelist: () -> Unit, @@ -277,6 +279,39 @@ fun HomeScreen( onRemoveSaved = onRemoveSavedEarbuds, ) } + + // "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/linux/ui-tauri/src-tauri/src/arbiter.rs b/linux/ui-tauri/src-tauri/src/arbiter.rs new file mode 100644 index 0000000..ccc7871 --- /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. + +// The switch half of this API (`force_activate`, `begin_switch`, +// `is_switching`, `end_switch`, `is_connected`, `is_active`) is written but not +// yet called: the Switch button and the candidate picker land next. Kept here +// rather than added piecemeal so the ownership rules live in one reviewed +// place, with the tests that pin them. Drop this allow once the UI is wired. +#![allow(dead_code)] + +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); + } +} + +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 5a513b9..d75ad57 100644 --- a/linux/ui-tauri/src-tauri/src/ble.rs +++ b/linux/ui-tauri/src-tauri/src/ble.rs @@ -1002,7 +1002,20 @@ pub(crate) async fn run_ble_persistent_loop( // 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::peer_cache::set_active_peer(&peer.peer_static_pub); + 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/cmd_pairing.rs b/linux/ui-tauri/src-tauri/src/cmd_pairing.rs index 3794904..649a1e6 100644 --- a/linux/ui-tauri/src-tauri/src/cmd_pairing.rs +++ b/linux/ui-tauri/src-tauri/src/cmd_pairing.rs @@ -178,7 +178,8 @@ pub(crate) async fn forget_peer(ctx: &WorkerCtx, hex_str: String) { // paths) BEFORE dropping the peer's directory and unsetting it. purge_peer_cache(&ctx.app); crate::peer_cache::remove_peer_dir(&arr); - crate::peer_cache::clear_active_peer(&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 @@ -245,7 +246,8 @@ pub(crate) async fn forget_all(ctx: &WorkerCtx) { crate::ble::forget_stale_device(&ctx.adapter, addr).await; } crate::peer_cache::remove_peer_dir(peer_pub); - crate::peer_cache::clear_active_peer(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/lib.rs b/linux/ui-tauri/src-tauri/src/lib.rs index 5e592c9..177032d 100644 --- a/linux/ui-tauri/src-tauri/src/lib.rs +++ b/linux/ui-tauri/src-tauri/src/lib.rs @@ -54,6 +54,7 @@ mod lan_state; mod live_activity; mod media_remote; mod mirror; +mod arbiter; mod mirror_inject; mod peer_cache; mod mirror_window; diff --git a/linux/ui-tauri/src-tauri/src/peer_cache.rs b/linux/ui-tauri/src-tauri/src/peer_cache.rs index 574620f..798b4c1 100644 --- a/linux/ui-tauri/src-tauri/src/peer_cache.rs +++ b/linux/ui-tauri/src-tauri/src/peer_cache.rs @@ -21,40 +21,6 @@ use std::path::PathBuf; -/// The peer whose data the phone-specific caches currently refer to. -/// -/// Interim owner of "which peer is active". The full arbiter (design doc -/// §D4 — separating *connected* from *active*) will take this over; until -/// then it is set from the single trusted peer at startup and refreshed -/// whenever a BLE session completes Noise IK. That reproduces today's -/// behaviour exactly for the single-peer case, which is every existing -/// install, while giving the cache paths a real peer to key on. -static ACTIVE_PEER: std::sync::Mutex> = std::sync::Mutex::new(None); - -/// Point the phone-specific caches at `peer_pub`. -pub(crate) fn set_active_peer(peer_pub: &[u8; 32]) { - if let Ok(mut g) = ACTIVE_PEER.lock() { - if g.as_ref() != Some(peer_pub) { - tracing::debug!(peer = %hex::encode(&peer_pub[..4]), "active peer for caches"); - } - *g = Some(*peer_pub); - } -} - -/// Forget the active peer if it is `peer_pub` (no-op for any other peer, so a -/// `ForgetPeer` for an inactive device cannot blank the active one's paths). -pub(crate) fn clear_active_peer(peer_pub: &[u8; 32]) { - if let Ok(mut g) = ACTIVE_PEER.lock() { - if g.as_ref() == Some(peer_pub) { - *g = None; - } - } -} - -fn active_peer() -> Option<[u8; 32]> { - ACTIVE_PEER.lock().ok().and_then(|g| *g) -} - /// `~/.cache/vortex` — the shared root (notes, clipboard, icons live here). fn cache_root() -> Option { let mut p = PathBuf::from(std::env::var_os("HOME")?); @@ -64,10 +30,14 @@ fn cache_root() -> Option { /// 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 = active_peer()?; + let peer = crate::arbiter::active()?; let mut p = cache_root()?; p.push("peers"); p.push(hex::encode(&peer[..8])); diff --git a/linux/ui-tauri/src-tauri/src/worker.rs b/linux/ui-tauri/src-tauri/src/worker.rs index 1199de2..5554b35 100644 --- a/linux/ui-tauri/src-tauri/src/worker.rs +++ b/linux/ui-tauri/src-tauri/src/worker.rs @@ -212,7 +212,7 @@ pub(crate) fn run_worker(app: AppHandle, cmd_rx: Receiver) { // 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::peer_cache::set_active_peer(&only.peer_static_pub); + crate::arbiter::claim(&only.peer_static_pub); } // BLE adapter. diff --git a/resume_session.sh b/resume_session.sh new file mode 100644 index 0000000..0ea3574 --- /dev/null +++ b/resume_session.sh @@ -0,0 +1 @@ +claude --resume c675e6f2-1cf0-49cc-9196-003bb697fc4d diff --git a/shared/proto/vortex.proto b/shared/proto/vortex.proto index 0b8ca88..6c10e26 100644 --- a/shared/proto/vortex.proto +++ b/shared/proto/vortex.proto @@ -43,6 +43,9 @@ message VortexMessage { Ping ping = 20; Pong pong = 21; ErrorFrame error = 22; + + // --- Multi-peer session ownership (30–39) --- + PeerHandoff peer_handoff = 30; } } @@ -189,6 +192,44 @@ message ErrorFrame { string message = 2; // diagnostics only; must not leak secrets } +// ============================================================================ +// 3b. Multi-peer session ownership +// ============================================================================ + +// Transfers or refuses ownership of the single active session. +// +// A device may TRUST many peers but is ACTIVE with only one at a time. This +// frame is how the two sides agree on which. It is additive: a peer that does +// not advertise CAP_PEER_HANDOFF in `capability_flags` never receives one, and +// an older build ignores the unknown oneof field rather than failing. +// +// `RELEASE` is sent by the side giving up ownership — the user pressed +// "Switch" and a replacement peer has already been chosen. Ownership flips +// atomically when it is sent; the transport link may linger and drop on its +// own afterwards. That ordering matters: while two links overlap, only one +// side may be active, or both laptops mirror notifications and sync clipboard +// at once. +// +// `BUSY` is a refusal, sent when a peer asks to become active while another +// already is. Without an explicit refusal a rejected peer cannot distinguish +// "no" from packet loss, and retries in a tight loop against the phone's +// single GATT link. +message PeerHandoff { + enum Kind { + KIND_UNSPECIFIED = 0; + RELEASE = 1; // you are no longer the active peer + BUSY = 2; // refused: another peer is active + CLAIM = 3; // request to become the active peer + } + Kind kind = 1; + // Why ownership moved, for the UI ("switched to ") and for logs. + // Diagnostics only — never secrets, per the ErrorFrame.message rule. + string reason = 2; + // Display name of the peer taking over, when known. Sanitised by the + // receiver before it reaches any UI (it is peer-supplied text). + string successor_name = 3; +} + // ============================================================================ // 4. Shared enums // ============================================================================ From e196c68d0e78b111868a25322cde57476c33f19a Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Tue, 25 Aug 2026 19:46:25 +0200 Subject: [PATCH 04/21] feat(multi-peer): "Switch device" on the laptop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the laptop half of switching between remembered phones: a tablet-smartphone button on the phone card that keeps the current device connected while it looks for another already-trusted one. Seek before release (design doc §D3). The active link is held for the whole scan, so the persistent reconnect loop has nothing to race back into and the laptop can never end up connected to nothing. A cancelled or fruitless switch leaves you exactly where you were, which is why no standby/suppression window is needed at all. The scan needs to know WHICH peer it found, not just "some trusted peer is nearby". expected_presence_tokens flattens every peer's tokens into one set — right for reconnect, useless for a switch, which must leave the active peer out. So scan_other_trusted_peers builds a token->peer map over the same ±2 bucket window, and collects for the full window instead of stopping at the first hit: whether there is ONE candidate (auto-adopt) or several (ask) must not depend on which phone advertised first. Strongest RSSI per peer wins, since it wobbles between advertising events. Ownership moves only in do_activate, and atomically (§D4) — the displaced peer stops being active immediately even though its transport may take a while to drop. Otherwise two phones would briefly both own the session and both mirror notifications and clipboard into this laptop. The peer hex from the webview is untrusted input, so it is checked against the peer store before anything moves. switch_peer() spawns rather than awaits: the worker's command loop is strictly sequential, so awaiting a 45 s scan would stall every other command behind it, including the 5 s earbuds heartbeat. UiCmd::Scan already spawns for exactly this reason. A second press while a window is open is ignored instead of starting a competing discovery on the same adapter. TrustedPeerDto gains `active`, because with several trusted phones the UI has to distinguish "remembered" from "the one whose SMS you are reading". UI: lucide tablet-smartphone for consistency with the rest of the icon set, shown only with more than one paired phone (switching is meaningless otherwise). Scanning spinner, candidate picker with RSSI, "no other paired phone nearby", and Cancel throughout. Strings in all three locales. Not yet wired: sending PeerHandoff.RELEASE to the displaced peer (marked TODO), and the whole phone-side half — Switch button, Seeking state machine, token multiplexing. End-to-end verification needs a second paired phone; what is verified here is cargo build, vue-tsc, and the app running and syncing against one. Co-Authored-By: Claude Opus 5 --- linux/ui-tauri/src-tauri/src/ble.rs | 115 +++++++++++++++ linux/ui-tauri/src-tauri/src/cmd_pairing.rs | 146 ++++++++++++++++++++ linux/ui-tauri/src-tauri/src/ipc.rs | 18 +++ linux/ui-tauri/src-tauri/src/lib.rs | 3 + linux/ui-tauri/src-tauri/src/pairing.rs | 18 +++ linux/ui-tauri/src-tauri/src/worker.rs | 5 + linux/ui-tauri/src/composables/useHome.ts | 48 ++++++- linux/ui-tauri/src/lib/bridge.ts | 41 ++++++ linux/ui-tauri/src/lib/locales/en.json | 7 +- linux/ui-tauri/src/lib/locales/ru.json | 7 +- linux/ui-tauri/src/lib/locales/uz.json | 7 +- linux/ui-tauri/src/pages/home/Devices.vue | 50 +++++++ 12 files changed, 461 insertions(+), 4 deletions(-) diff --git a/linux/ui-tauri/src-tauri/src/ble.rs b/linux/ui-tauri/src-tauri/src/ble.rs index d75ad57..39657ec 100644 --- a/linux/ui-tauri/src-tauri/src/ble.rs +++ b/linux/ui-tauri/src-tauri/src/ble.rs @@ -297,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, diff --git a/linux/ui-tauri/src-tauri/src/cmd_pairing.rs b/linux/ui-tauri/src-tauri/src/cmd_pairing.rs index 649a1e6..39da37b 100644 --- a/linux/ui-tauri/src-tauri/src/cmd_pairing.rs +++ b/linux/ui-tauri/src-tauri/src/cmd_pairing.rs @@ -126,6 +126,152 @@ 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], +) { + // 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 { + // TODO(multi-peer): send PeerHandoff.RELEASE to `prev` so it stops + // presenting itself as connected. Until the frame is wired, the + // displaced phone learns of it on its next contact. + tracing::info!( + displaced = %hex::encode(&prev[..4]), + "displaced peer still needs a PeerHandoff.RELEASE" + ); + } + 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; +} + /// `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) { 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/lib.rs b/linux/ui-tauri/src-tauri/src/lib.rs index 177032d..9cee075 100644 --- a/linux/ui-tauri/src-tauri/src/lib.rs +++ b/linux/ui-tauri/src-tauri/src/lib.rs @@ -367,6 +367,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/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/worker.rs b/linux/ui-tauri/src-tauri/src/worker.rs index 5554b35..f4fff5c 100644 --- a/linux/ui-tauri/src-tauri/src/worker.rs +++ b/linux/ui-tauri/src-tauri/src/worker.rs @@ -709,6 +709,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") }}
+ +
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/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. From 92ff4bc2461b16f1f595a2dd3d470c77a4ec5b65 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Wed, 26 Aug 2026 16:14:24 +0200 Subject: [PATCH 11/21] fix(share): don't crash the app on an oversized file share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sharing an 835 MB file killed the whole process. Captured on device: OutOfMemoryError: Failed to allocate a 876288016 byte allocation with 25165824 free bytes and 253MB until OOM, growth limit 268435456 at ClipboardFileReader.read(ClipboardFileOut.kt:27) at ShareReceiverActivity.onCreate(ShareReceiverActivity.kt:81) ActivityManager: Killing 15355:io.github.zoir_dev.vortex (adj 50): crash Three defects stacked: * `readBytes()` buffered the entire file BEFORE anything checked its size, so the 64 MB guard was unreachable for exactly the files it existed to stop. 876 MB cannot be allocated against a 256 MB growth limit. * `OutOfMemoryError` is an Error, not an Exception, so `catch (e: Exception)` did not catch it. It escaped onCreate and took the process down — with the BLE/LAN service and the notification listener. That is why the user saw a crash and no message: the app was dead before it could report anything. * Even the graceful path only wrote to logcat, so a rejected share looked identical to a broken one from the outside. Now: the size is pre-flighted from OpenableColumns.SIZE before anything is allocated; the read is bounded to cap+1 bytes so a provider that misreports or omits the size still cannot blow the heap; OutOfMemoryError is caught narrowly as a backstop for that case; and the toast names the real reason with real numbers ("File is too big to send (835 MB; limit 64 MB)") instead of "Couldn't read the shared file(s)", which is what made a deliberate limit look like a malfunction. This does NOT make large files transferable — the 64 MB cap and the buffer-the-whole-file design are still there, and raising the constant would only move the OOM. Ranged streaming is the actual fix; see the file-browsing design doc, which needs the same primitive. Verified on device: same 835 MB file now shows the size toast and the app survives. Co-Authored-By: Claude Opus 5 --- .../a3/core/clipboard/ClipboardFileOut.kt | 109 +++++++++++++----- .../core/clipboard/ShareReceiverActivity.kt | 36 ++++-- 2 files changed, 103 insertions(+), 42 deletions(-) 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..f8e4543 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,98 @@ 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. + */ + 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..87f8f6d 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 @@ -77,25 +77,39 @@ class ShareReceiverActivity : Activity() { } var sent = 0 + var tooLarge = 0 + var largestRejected = 0L 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)) { + when (val outcome = ClipboardFileReader.read(this, uri)) { + is ClipboardFileReader.Outcome.Ok -> { + val file = outcome.file + 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}'") } - } else { - Log.w(TAG, "share: couldn't read $uri") + is ClipboardFileReader.Outcome.TooLarge -> { + tooLarge++ + largestRejected = maxOf(largestRejected, outcome.bytes) + Log.w(TAG, "share: $uri over the size cap (${outcome.bytes} bytes)") + } + is ClipboardFileReader.Outcome.Unreadable -> { + Log.w(TAG, "share: couldn't read $uri (${outcome.why})") + } } } + // Name the actual reason. "Couldn't read the shared file" for a file the + // user can see perfectly well is what made an over-cap share look like a + // bug rather than a limit — and before the size pre-check, a big one + // took the whole app down without saying anything at all. val msg = when { + sent == 0 && tooLarge > 0 -> { + val cap = ClipboardFileReader.MAX_FILE_BYTES / (1024 * 1024) + val mb = largestRejected / (1024 * 1024) + if (tooLarge == 1) "File is too big to send ($mb MB; limit $cap MB)" + else "$tooLarge files are too big to send (limit $cap MB)" + } sent == 0 -> "Couldn't read the shared file(s)" + tooLarge > 0 -> "Sending $sent; $tooLarge too big to send" sent == 1 -> "Sending file to laptop…" else -> "Sending $sent files to laptop…" } From 6820f833b076725d4b4ad1e06a6dffc77fc2092a Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Wed, 26 Aug 2026 18:33:09 +0200 Subject: [PATCH 12/21] fix(share): queue a multi-file share instead of silently dropping most of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sharing 150 files delivered about 20 and reported success for all of them. Two silent losses compounded: * `clipboardFileBus` had 4 slots with DROP_OLDEST. The collector (read → stash → JSON → BLE notify) cannot drain that as fast as a share loop fills it, so the overflow was discarded. Worse, `tryEmit` returns TRUE on a drop, so the sender counted every file as sent — the toast promised ~130 files that never existed. * ClipboardBlobStore evicts past MAX_ENTRIES, and the laptop pulls one file at a time, so a queued file's bytes could be gone before its turn. Capping the batch would make that honest but refuses work the user asked for, which is worse UX than taking longer. So the whole list is accepted and paced: * ShareQueue holds URIs, NOT bytes, and reads each file on its turn. Reading 150 files up front is what made an 835 MB share an OutOfMemoryError; memory is now flat whether the batch is 5 files or 500. * At most WINDOW files are in flight, measured against `pendingOffers` — offers the laptop has not collected — so pacing follows real delivery rather than a timer, and never exceeds what the blob store holds. * Progress is ONE updating notification with a real progress bar, replacing the per-file "File sent: X" toast. 150 toasts was its own bug. * Files too large or unreadable are counted and reported at the end instead of aborting the batch. Deferring the read moved it out of the Activity (which finishes immediately) into the service, and the share sheet's read grant does not reach that far on its own — it is carried across via ClipData + FLAG_GRANT_READ_URI_PERMISSION. Plain intent extras would have handed the service URIs it could not open. The bus keeps a backstop: buffer sized to MAX_ENTRIES and overflow switched to SUSPEND, so `tryEmit` now reports refusal instead of discarding, and the queue retries rather than lying. Verified on device: 65 files, all 65 delivered. Co-Authored-By: Claude Opus 5 --- .../a3/core/clipboard/ClipboardBlobStore.kt | 9 +- .../core/clipboard/ShareReceiverActivity.kt | 78 ++++---- .../java/com/vortex/a3/service/ShareQueue.kt | 177 ++++++++++++++++++ .../com/vortex/a3/service/VortexService.kt | 54 +++++- .../java/com/vortex/a3/service/VortexStack.kt | 20 ++ .../a3/service/VortexStackOfferRetry.kt | 6 +- 6 files changed, 305 insertions(+), 39 deletions(-) create mode 100644 android/app/src/main/java/com/vortex/a3/service/ShareQueue.kt 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/ShareReceiverActivity.kt b/android/app/src/main/java/com/vortex/a3/core/clipboard/ShareReceiverActivity.kt index 87f8f6d..ec7a42c 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 @@ -76,42 +76,52 @@ class ShareReceiverActivity : Activity() { } } - var sent = 0 - var tooLarge = 0 - var largestRejected = 0L - for (uri in uris) { - when (val outcome = ClipboardFileReader.read(this, uri)) { - is ClipboardFileReader.Outcome.Ok -> { - val file = outcome.file - VortexService.clipboardFileBus.tryEmit(file) - Log.i(TAG, "share: forwarded file '${file.name}' (${file.bytes.size} bytes)") - sent++ - } - is ClipboardFileReader.Outcome.TooLarge -> { - tooLarge++ - largestRejected = maxOf(largestRejected, outcome.bytes) - Log.w(TAG, "share: $uri over the size cap (${outcome.bytes} bytes)") - } - is ClipboardFileReader.Outcome.Unreadable -> { - Log.w(TAG, "share: couldn't read $uri (${outcome.why})") - } - } + // 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 } - // Name the actual reason. "Couldn't read the shared file" for a file the - // user can see perfectly well is what made an over-cap share look like a - // bug rather than a limit — and before the size pre-check, a big one - // took the whole app down without saying anything at all. - val msg = when { - sent == 0 && tooLarge > 0 -> { - val cap = ClipboardFileReader.MAX_FILE_BYTES / (1024 * 1024) - val mb = largestRejected / (1024 * 1024) - if (tooLarge == 1) "File is too big to send ($mb MB; limit $cap MB)" - else "$tooLarge files are too big to send (limit $cap MB)" + 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 { + startService(svc) } - sent == 0 -> "Couldn't read the shared file(s)" - tooLarge > 0 -> "Sending $sent; $tooLarge too big to send" - sent == 1 -> "Sending file to laptop…" - else -> "Sending $sent files to laptop…" + } 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 + } + 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/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 fe1fc93..9fd1fcf 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, ) 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 abca893..1082d34 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 @@ -91,6 +91,9 @@ 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 /** 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 @@ -111,6 +114,18 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { * 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. */ internal var callLogProvider: com.vortex.a3.core.calllog.CallLogProvider? = null @@ -1219,6 +1234,11 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { * 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/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 From ab4d620ef6b57564de1be5025d1637535d120ae6 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Wed, 26 Aug 2026 18:33:23 +0200 Subject: [PATCH 13/21] fix(ble): advertise when ACL-connected but sessionless MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A regression from `Active = silent` (bf43359). `linkedProvider` was wired to `hasActiveConnection()`, which only means some central holds an ACL link — and BlueZ owns the ACL, not the app, so it survives the laptop app being restarted, killed or updated. The result was a deadlock. The phone saw "connected", stayed silent, and became unreachable; the laptop had no session and needed an advertisement to find the phone; neither side broke the tie. Since file offers ride BLE only, they sat retrying "BLE link down?" forever while LAN heartbeats kept working — so everything else looked healthy. Observed live while testing a 65-file share: the queue correctly held its files, but no offer could ever go out. Now keyed on `hasAudioSignalSubscriber()`: a peer that has actually subscribed to the notify channel, i.e. the path is genuinely deliverable. That is exactly the condition under which advertising is pointless, and it comes apart from mere ACL connectivity in precisely the case that broke. Verified: the phone advertised WHILE ACL-connected (previously impossible), the laptop found it, subscribed, and only then did the phone go silent. Note this was reachable in normal use, not just from my restarts — any laptop crash or app update leaves the same stale ACL. Co-Authored-By: Claude Opus 5 --- .../main/java/com/vortex/a3/core/ble/GattServer.kt | 14 ++++++++++++++ .../main/java/com/vortex/a3/service/VortexStack.kt | 8 +++++++- 2 files changed, 21 insertions(+), 1 deletion(-) 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 68b617e..a994f70 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 @@ -767,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 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 1082d34..5df035c 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 @@ -850,7 +850,13 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { // avoidable battery cost here (design doc §D5). adv.linkedProvider = provider@{ val srv = gattServer ?: return@provider false - srv.hasActiveConnection() + // 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 From ca41abb7949ac200f1de272d0cba8f492a68f4fe Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Wed, 26 Aug 2026 18:33:46 +0200 Subject: [PATCH 14/21] fix(wifi-direct): only switch networks when it actually helps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 65-file share produced dozens of Wi-Fi disconnect/reconnect notifications and kept cutting transfers off mid-file. Four separate causes, found one at a time: 1. The OFFER was sent per file. `WifiDirect.start` is idempotent for the group but re-invokes its callback on every call (`if (isUp) onReady()`), and it runs once per file over 4 MB — so 65 files meant 65 offers, each making the laptop join the group and restore its Wi-Fi. Now coalesced with a minimum gap, cleared on teardown so a later batch is never starved. 2. The laptop restored Wi-Fi the instant its pull queue emptied. A paced sender empties it between windows, so every gap cost a full leave+rejoin. Now the group link is held across a 25 s idle grace — under the phone's 60 s GO teardown, so we let go before the group vanishes underneath us. 3. The watchdog force-restored 60 s after joining regardless of progress, cutting any batch that legitimately took longer (observed: file 26 of 65). It now polls for STALL instead of sleeping out a deadline. 4. That stall check then used `file_pull_active()`, which ANDs progress with "queue non-empty" — and a paced sender makes the queue oscillate, so a healthy transfer read as stalled and it fired anyway (file 21 of 65). It now measures time since a file last COMPLETED (`queue_progress_age`), and abstains entirely when nothing is queued, leaving that decision to the idle grace. Two mechanisms both deciding was the bug. Which left the real question: why switch at all? The trigger was "some file is over 4 MB", so a share went to Wi-Fi Direct even with both devices on the same AP, where the router path already works. Wi-Fi Direct costs BOTH devices their AP association — unavoidable with one radio each — so it is now skipped when the peer is already reachable on our LAN. That test is written to be portable, because the Windows port needs it: * the local address that reaches the peer comes from a connected UDP socket (`connect` sends nothing; it just makes the kernel do the route lookup so `local_addr` reports the source it would pick) — same on Windows, and a better question than enumerating interfaces and guessing; * netmasks come from `if-addrs`, which wraps getifaddrs / GetAdaptersAddresses and was already in the lockfile transitively. `is_fast_lan_iface` isolates the one genuinely platform-specific part, since if-addrs does not report interface TYPE. It is a denylist (bnep/ppp/wwan/rmnet/ tun/tap/p2p) on purpose: a misnamed fast link only costs a pointless P2P group, whereas a missed Bluetooth PAN would silently route a large transfer over Bluetooth. TODO left for the Windows port to use IfType properly. Verified on device: a 65-file share now completes with ZERO network switches. Co-Authored-By: Claude Opus 5 --- .../a3/service/VortexStackWifiDirect.kt | 23 ++ linux/ui-tauri/src-tauri/Cargo.lock | 1 + linux/ui-tauri/src-tauri/Cargo.toml | 4 + linux/ui-tauri/src-tauri/src/lan.rs | 38 +++- .../ui-tauri/src-tauri/src/lan_wifi_direct.rs | 215 +++++++++++++++++- 5 files changed, 271 insertions(+), 10 deletions(-) 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/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/lan.rs b/linux/ui-tauri/src-tauri/src/lan.rs index 14e438a..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 @@ -115,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; @@ -765,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, @@ -880,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_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; + } } }); }); From a70a115d41809e2de500b647ec91c59761d10a88 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Wed, 26 Aug 2026 18:34:46 +0200 Subject: [PATCH 15/21] fix(files): make an incoming pull idempotent per content token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defence in depth, not the cure. A 65-file share arrived as 75 files with duplicates, and the actual cause was the Wi-Fi Direct thrash (62e7a43): pulls were cut off mid-transfer, leaving partially-written files that had already been allocated a name in Downloads. Fixing the thrash is what stopped it. The receiver was nevertheless not idempotent, and that is worth closing on its own. The dedupe only covered offers still WAITING in the queue, so there is a window between the laptop dequeuing an offer and the phone learning it was served; a re-announce landing inside it passes the queued check, gets queued again, and is pulled twice. Completion now records the content token, and the dedupe also rejects anything pulled within RECENT_PULL_TTL. Fixed on the receiver deliberately: the phone cannot know about that window, so the side that owns the file has to be the one that refuses the repeat. The TTL is 60 s — long enough to outlast the announce/serve race, short enough that deliberately re-sharing the same file is not mysteriously ignored. That tension is real: tokens are content hashes, so "the same file" and "identical bytes" are indistinguishable here. Not verified as a fix in its own right — a 65-file share now arrives as exactly 65 files, but with the thrash gone the race it guards against no longer reproduces on demand. Still open: a failed pull leaves the partially-written file in Downloads. It should write to a temp name and rename on completion, so an interrupted transfer leaves nothing rather than a plausible-looking broken file. Co-Authored-By: Claude Opus 5 --- .../ui-tauri/src-tauri/src/clipboard_sync.rs | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) 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() { From 95142ccda5bc96389365d8a63a40699cf91e044a Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Wed, 26 Aug 2026 18:34:46 +0200 Subject: [PATCH 16/21] docs: design for browsing the phone's files from the desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The KDE-Connect-style feature: open the phone's storage in Dolphin / Nautilus / Explorer. Linux AND Windows from the start, since the Windows port branch means every new feature needs both. Records the decisions taken, and why: * one missing primitive underlies this AND large-file transfer — a ranged read. File managers issue them constantly; buffering whole files is what crashed the app on an 835 MB share. There is no offset-based read anywhere in the tree today, so both features start from the same standing start, and the 64 MB cap disappears as a side effect rather than as its own change; * the phone serves a dumb, narrow protocol; ALL caching, readahead and invalidation live in the daemon. 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 and proxies over the existing Noise session, so pairing IS the auth model — no second credential system, no TLS on the phone, nothing exposed on the network; * WebDAV over loopback first (one implementation, both OSes), with FUSE + ProjFS as the exit. ProjFS ships in Windows 10 1809+ with no third-party install, which is what beats WebDAV — whose Windows client defaults to a ~50 MB FileSizeLimitInBytes, i.e. trading a 64 MB cap for a 50 MB one; * SFTP+sshfs (what KDE Connect uses) is rejected on the Windows requirement alone: it needs WinFsp + SSHFS-Win, a third-party install per user; * writes and metadata-set are stubs that return an explicit "not supported" error, never silence — a stub that looks like a timeout hangs the file manager, which is the same class of bug as the silent share failures. Sequencing puts the ranged-read primitive and reworking large-file transfer onto it first, because both are worth doing whether or not the mount ever ships. Co-Authored-By: Claude Opus 5 --- docs/design/file-browsing.md | 230 +++++++++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 docs/design/file-browsing.md 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)? From 5a4c9f3c73c639e9b758c6d46958fbdceeab79fd Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Thu, 27 Aug 2026 11:55:14 +0200 Subject: [PATCH 17/21] =?UTF-8?q?fix(ui):=20make=20the=20phone's=20laptop?= =?UTF-8?q?=20card=20show=20=E2=80=94=20and=20offer=20=E2=80=94=20the=20ri?= =?UTF-8?q?ght=20device?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With two trusted laptops the card showed the wrong one, and its switch action was invisible as a control. Observed after pairing a second laptop overnight: arriving at work, the phone had reconnected to "Kapital" by itself and was syncing over both LAN and BLE, while the card read "gaia / Disconnected" — a laptop 40 km away. Wrong peer. Two compounding causes, both "an arbitrary peer" dressed up as a choice: * `HomeScreen` picked `peers.firstOrNull()`, which has no notion of which peer is active; * `EncryptedPrefsPeerStore.list()` reads `prefs.all`, a HashMap, so the sequence followed HASH ORDER. Every `list().firstOrNull()` in the phone codebase therefore meant "whichever peer hashed first". The card now takes the peer with the freshest traffic, tie-broken by `pairedAt`, so it follows whatever laptop is really on the other end and falls back to most-recently-paired when nothing has been heard yet. `list()` is sorted by `pairedAt` descending so the nondeterminism is gone at source, for the other single-peer callers too. Worth recording: restarting the app would have APPEARED to fix this, because a fresh hash order might have landed on the right peer — it would have looked like a transient glitch rather than a reproducible bug. Invisible action. It sat in the card's bottom row between the cast and lock glyphs: on screen and tappable (uiautomator confirmed clickable, bounds [482,1152][650,1320]) but unreadable as something pressable. A two-facing-arrows glyph sandwiched between two other icons reads as "swap those two", and the row was crowded enough to wrap the battery percentage onto a second line. It now renders beside the device icon via a new optional `afterIcon` slot on `CardHeader`, using `PhonelinkOff` — "unlink", which is what leaving this laptop for another one is. Icon and action are grouped in an inner Row so the header's SpaceBetween keeps them left instead of spreading three items. Verified on device: card went from "gaia / Disconnected" to "Kapital / Connected / 100%"; action moved to [273,757][441,925]; battery back to a single line. Co-Authored-By: Claude Opus 5 --- .../core/storage/EncryptedPrefsPeerStore.kt | 7 +++ .../com/vortex/a3/ui/components/Common.kt | 29 ++++++++---- .../vortex/a3/ui/components/PeerDeviceCard.kt | 45 ++++++++++--------- .../com/vortex/a3/ui/screens/HomeScreen.kt | 13 +++++- 4 files changed, 64 insertions(+), 30 deletions(-) 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 e6cd4a7..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 @@ -201,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() } 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/PeerDeviceCard.kt b/android/app/src/main/java/com/vortex/a3/ui/components/PeerDeviceCard.kt index d9c9373..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,7 +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.PhoneAndroid +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 @@ -101,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) @@ -135,26 +158,6 @@ fun PeerDeviceCard( } Spacer(modifier = Modifier.size(8.dp)) } - if (onSwitch != null) { - // Switch to another remembered laptop. Tinted while seeking so - // the state is visible without stealing card space for a label - // — the window closes itself, so there is nothing to undo. - Icon( - imageVector = Icons.Outlined.PhoneAndroid, - contentDescription = "Switch to another laptop", - tint = if (seeking) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier - .clip(RoundedCornerShape(8.dp)) - .clickable(onClick = onSwitch) - .padding(4.dp) - .size(20.dp), - ) - Spacer(modifier = Modifier.size(8.dp)) - } if (locked != null && onToggleLock != null) { Icon( imageVector = if (locked) Icons.Outlined.Lock else Icons.Outlined.LockOpen, 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 d68adf2..d0bcd02 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 @@ -108,7 +108,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 From 19d989feea7fe5cba290ed1fe8a0659d226b84ac Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Thu, 27 Aug 2026 14:38:14 +0200 Subject: [PATCH 18/21] feat(ui): show every paired laptop, and switch to a named one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phone rendered a single laptop card, so with two trusted laptops the second was invisible — no way to see it was still paired, and no way to choose it. This is the per-peer view from design doc §D8. An "Also paired" section lists every laptop except the one in the main card, and tapping a row switches to THAT laptop. Compact rows rather than a card each: PeerDeviceCard is 180 dp, so a card per laptop would push the rest of the screen away for what is mostly "this one exists and is not the one you are on". The row itself is the tap target — a 20 dp glyph is a poor one, and there is a single action per row — so there is no per-row "Switch" label; the heading says it once. Naming the destination is not just UI. `startSeeking` now takes an optional target, and the presence provider advertises ONLY that peer's token instead of cycling all remembered ones. So a targeted switch is found as fast as the single-peer case and spends less time on air, which is the §D1 observation that one advertisement suffices when the user picks where they are going. An untargeted seek (the card header's unlink action) still multiplexes. Each row reports when the laptop was last heard from, falling back to when it was paired. `peerLastSeen` only covers peers seen 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. Verified on device with two real peers: main card "Kapital / Connected / 100%", "Also paired" listing "gaia" with its last-seen line, and the targeted-seek handler wired through. The row-label removal that followed is compile-verified only — the phone locked before it could be re-captured. Co-Authored-By: Claude Opus 5 --- .../com/vortex/a3/service/VortexService.kt | 3 +- .../java/com/vortex/a3/service/VortexStack.kt | 21 ++- .../java/com/vortex/a3/ui/MainActivity.kt | 1 + .../com/vortex/a3/ui/MainActivityPairing.kt | 23 +++ .../src/main/java/com/vortex/a3/ui/Strings.kt | 6 + .../main/java/com/vortex/a3/ui/VortexRoot.kt | 3 + .../vortex/a3/ui/components/OtherPeersCard.kt | 150 ++++++++++++++++++ .../com/vortex/a3/ui/screens/HomeScreen.kt | 13 ++ 8 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/java/com/vortex/a3/ui/components/OtherPeersCard.kt 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 9fd1fcf..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 @@ -698,7 +698,8 @@ class VortexService : Service() { * remembered laptops, so the UI can leave the button disabled rather * than opening a window that cannot succeed. */ - fun startSeeking(): Boolean = liveStack?.startSeeking() ?: false + 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). */ 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 5df035c..df0694e 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 @@ -94,6 +94,16 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { /** 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 @@ -872,6 +882,13 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { 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 } @@ -895,7 +912,7 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { * Open a seek window: advertise to the other remembered laptops while * staying connected to the current one. See [VortexService.startSeeking]. */ - fun startSeeking(): Boolean { + 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. @@ -905,6 +922,7 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { 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. @@ -928,6 +946,7 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { 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() 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 5d74669..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 @@ -449,6 +449,7 @@ class MainActivity : ComponentActivity() { 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 42d1d90..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 @@ -259,6 +259,29 @@ internal fun MainActivity.onSwitchLaptopClicked() { } } +/** + * 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( 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 8b8d2f2..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 @@ -53,6 +53,8 @@ private val EN = mapOf( "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", @@ -193,6 +195,8 @@ private val UZ = mapOf( "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", @@ -333,6 +337,8 @@ private val RU = mapOf( "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 "Не подключены", 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 fcfb6d6..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 @@ -83,6 +83,8 @@ class VortexActions( 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, @@ -268,6 +270,7 @@ fun VortexRoot( onAddPair = actions.onAddPair, onCancelAddPair = actions.onCancelAddPair, onSwitchLaptop = actions.onSwitchLaptop, + onSwitchToPeer = actions.onSwitchToPeer, seekingLaptop = ui.seekingLaptop.collectAsState().value, onOpenAutostart = actions.onOpenAutostart, onDismissAutostartHint = actions.onDismissAutostartHint, 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/screens/HomeScreen.kt b/android/app/src/main/java/com/vortex/a3/ui/screens/HomeScreen.kt index d0bcd02..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 @@ -90,6 +91,7 @@ fun HomeScreen( onAddPair: () -> Unit, onCancelAddPair: () -> Unit, onSwitchLaptop: () -> Unit, + onSwitchToPeer: (TrustedPeer) -> Unit, seekingLaptop: Boolean, onOpenAutostart: () -> Unit, onDismissAutostartHint: () -> Unit, @@ -297,6 +299,17 @@ fun HomeScreen( ) } + // 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 From 7f9829861d2db38aff4c815343560b697b7f929b Mon Sep 17 00:00:00 2001 From: X-Ryl669 Date: Fri, 28 Aug 2026 08:33:17 +0200 Subject: [PATCH 19/21] fix(ble): drop a peer's CCCD subscriptions when its link dies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to f49636f, which moved `linkedProvider` off `hasActiveConnection()` and onto `hasAudioSignalSubscriber()`. That removed the stale-ACL wedge but opened a strictly worse one: `onConnectionStateChange(DISCONNECTED)` prunes `connectedAddrs`, `deviceMtu`, the prepared-write buffers and the orchestrator state — but never the three subscriber sets. A CCCD subscription only ever disappears on an explicit 0x0000 write or in `stop()`, and a central whose link just dropped never gets to write anything. So every clean disconnect now leaves a phantom AUDIO_SIGNAL subscriber, and the presence loop suspends advertising *permanently*: the phone believes a session is live, goes off air, and cannot be found by the very laptop it is waiting for. Nothing breaks the tie but an app restart. Before f49636f `connectedAddrs` was pruned here, so the old code self-healed from a clean drop — the regression traded a rare wedge for one that fires on every disconnect. Observed live this morning: the laptop app restarted at 20:08, and the phone advertised nothing for the following ten hours. The laptop scanned every five minutes and found nothing, so laptop→phone clipboard sat pending and expired at its 300s TTL, while LAN heartbeats kept syncing and both ends showed "connected". Force-stopping the phone app (which calls `stop()`) restored it: advertising resumed within 20ms, and the laptop connected, subscribed and registered its BLE audio writer 3.5s later. Co-Authored-By: Claude Opus 5 --- .../main/java/com/vortex/a3/core/ble/GattServer.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 a994f70..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 @@ -837,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}") } From c1246424af5ced2a1bb1f9db80ce870f3851e088 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Fri, 28 Aug 2026 10:39:39 +0200 Subject: [PATCH 20/21] fix(handoff): open a shared page once, not every 12 seconds forever MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sharing a link from the phone re-opened it in the laptop's browser every ~12s and never stopped. Caught live: 67 zombie `xdg-open` children under the Vortex UI, one every 12.4s across the preceding 831s, with a fresh one at age 0. Nothing the user could do on the phone stopped it — copying other text doesn't touch the handoff bus at all, and only the accessibility read (which needs that service enabled) ever writes the clearing empty url. An explicit Share is a one-shot COMMAND, but it was being carried as STATE: * `forwardHandoff` stashes the event in `VortexService.currentHandoff` so a dead BLE link still gets it over LAN — and then never retracts it. * every AppState snapshot republishes it, on both the LAN heartbeat (lan.rs) and the BLE STATE frame (lan_state.rs). * the laptop's `open_now` branch called `open_url` unconditionally. Both dispatch sites claimed "consumer dedups by URL"; it never did. Fixed at both ends, because either alone leaves a hole. `HandoffEvent` gains an `id` identifying one Share request, and the consumer opens a given id exactly once. Keyed on the id and not the URL, so deliberately re-sharing the same page still opens it. An empty id (the live-read path, or a phone build predating the field) falls back to deduping by URL: those cannot express "again", and stopping the loop matters more. This also closes a latent double-open — the BLE frame and the AppState carry both landing opened two tabs. The phone additionally expires an `openNow` carry after 45s, long enough for a BLE-down laptop to collect it off a heartbeat, and retracts only its own event so a newer page isn't clobbered. `open_url` also moves to `tokio::process`: a `std` Child dropped without `wait()` stays a zombie for the parent's whole life, which is where the 67 came from. The notification-action opener already did it this way. Verified end to end on the debug build. One share opened at 08:37:22; the heartbeat re-delivered the same request 12.2s later and it was suppressed — exactly the cadence that produced the zombies. A second share of the same URL opened normally. Zero `xdg-open` children throughout. Known limit: the consumer's memory is in-process, so a laptop restart inside the 45s carry window opens the page once more. Co-Authored-By: Claude Opus 5 --- .../core/clipboard/ShareReceiverActivity.kt | 11 +++- .../vortex/a3/core/handoff/HandoffEvent.kt | 9 ++++ linux/daemon/src/core/handoff.rs | 12 +++++ linux/ui-tauri/src-tauri/src/handoff.rs | 51 +++++++++++++++++-- linux/ui-tauri/src-tauri/src/lan_state.rs | 3 +- 5 files changed, 81 insertions(+), 5 deletions(-) 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 ec7a42c..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() 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/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/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/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); From b5ba77db1debd0a12208508be3373990acfb2ae5 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Sat, 12 Sep 2026 18:31:33 +0200 Subject: [PATCH 21/21] fix(rebase): reconcile this branch's APIs with upstream's new callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fallout from rebasing onto origin/main, which grew callers for two APIs this branch had already replaced. Both replacements are kept — they are the better of each pair — and upstream's callers are adapted to them. `ClipboardFileReader.read` returns a typed `Outcome` here, where upstream still returns a nullable file. The Outcome is worth keeping: it distinguishes "too large" from "unreadable", which is the difference between a problem the user can fix and one they cannot, and the share sheet reports both. But upstream's MediaStore auto-send and its new phone-file reader have nowhere to put a reason, so they get `readOrNull` rather than a downgrade of the API. `Advertiser.startPresenceLoop` took an `isConnected` lambda upstream and reads `linkedProvider` here. The call site 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, which cannot outlive its session. The argument is dropped rather than reinstated. Kotlin compiles, Android unit tests pass, 62 + 161 Rust tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/vortex/a3/core/clipboard/ClipboardFileOut.kt | 9 +++++++++ .../src/main/java/com/vortex/a3/core/files/PhoneFiles.kt | 2 +- .../src/main/java/com/vortex/a3/service/VortexStack.kt | 9 ++++++++- .../java/com/vortex/a3/service/VortexStackClipboard.kt | 4 ++-- 4 files changed, 20 insertions(+), 4 deletions(-) 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 f8e4543..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 @@ -47,6 +47,15 @@ object ClipboardFileReader { * -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" 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/service/VortexStack.kt b/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt index df0694e..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 @@ -894,10 +894,17 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { .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, "presence loop started (have ${peerStore.list().size} peer(s))") 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)