Conversation
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 <noreply@anthropic.com>
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/<hex(peer_static_pub)[0..16]>/…
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
The phone half of switching laptops: an advertising phase machine, token multiplexing across remembered laptops, and a "switch laptop" seek window (docs/design/multi-peer.md §D1/§D5/§D9). Active = silent. The presence loop now advertises NOTHING while a session is live; it used to beacon 24/7 regardless. This is the largest avoidable battery cost in the design, because the phone is connected most of the time. It is safe for the laptop's proximity auto-lock precisely because that treats "authenticated session OR token-validated advertisement" as presence, and a disconnect kicks us back on air immediately. The loop also re-checks every 15 s rather than once per rotation window, so a *dropped* disconnect callback can leave us silent — and therefore invisible — for seconds, not a minute. Token multiplexing. The advertisement carries exactly one 8-byte token and ADV_IND is already at the legacy 31-byte ceiling, so N laptops cannot be addressed at once. With several peers the loop cycles them at 1.5 s dwell, so any of them sees us within N × dwell — nothing on a deliberate walk-up. With ONE peer it deliberately does not cycle: restarting the advertiser churns the RPA and costs battery, so the common case keeps the old one-advertise-per-bucket behaviour exactly. The dwell is also the floor on advertiser restarts, and each restart re-randomises the RPA — shorter dwells would inflate the laptop's BlueZ device cache and feed the stale-RPA connect wedge. Seek before release (§D3): startSeeking() drops nothing. The current link is held for the whole window and only the arrival of a *different* laptop completing IK ends it, so a cancelled or expired seek leaves the phone exactly where it was. Bounded at 45 s to match the laptop's SWITCH_WINDOW_SECS, and refused outright with fewer than two remembered laptops rather than opening a window that can only time out. `seeking` had to become its own term in the advertise-mode choice. fastModeProvider means "link is DOWN and was lost recently", but a seek keeps the current link UP by design — so it evaluated false exactly when the dense schedule matters most, with the user walking to another machine. Seeking now forces LOW_LATENCY, which is the first rung of the §D5 ladder. activePeerPub records which laptop a link belongs to, learned from IK (a BLE address alone does not identify a peer). The seek peer set excludes it: the user pressed Switch precisely because they want a different laptop, and spending dwell slots on the current one would only slow the others down. UI: a switch action on the laptop card, offered only with 2+ remembered laptops, tinted while seeking; pressing again cancels. The Activity mirrors the window state from the service on the existing 3 s ticker rather than keeping a second copy, so a window that expires on its own stops showing as busy without needing a callback. Also narrows arbiter.rs's module-wide allow(dead_code) to just is_connected now that the laptop's Switch UI uses the rest of that API — a blanket allow would hide genuinely dead code later. NOT RUNTIME-VERIFIED: compiles, and the existing Android unit tests plus the arbiter tests pass, but the phone was off the network when this landed. The switch flow end-to-end still needs a second paired laptop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The call log never synced at all. Both reads in CallLogProvider appended
`LIMIT n` to the `sortOrder` argument of ContentResolver.query, and the
call-log provider validates that argument:
CallLogProvider: emitSnapshot: Invalid token LIMIT
DatabaseUtils: at com.android.providers.contacts.CallLogProvider.query
Seen on Android 16 / OnePlus PJZ110. The row cap is redundant there anyway —
queryCallLog already stops at `cap` in Kotlin — so dropping it from the SQL is
behaviour-preserving.
Only ONE of the two failures was visible, which is why this hid for so long:
* readHistorySince() surfaced as `call_log_history: "error"` in the laptop's
bulk-sync status, on every single sync.
* readCallLog() failed just as hard but silently: emitSnapshot() catches and
logs under a different tag, so latestCallLogJson stayed null, and a null
from the bulk provider means "peer cache is current" — reported to the
laptop as `"match"`. A dataset that could never be produced was
indistinguishable from one that needed no update.
Verified on device: the `Invalid token LIMIT` failures are gone and the laptop
went from `call_log_history: "error"` to `"match"` with 49690 bytes of history
actually transferred (`call_log_history sent (49690 bytes since=0)`), where
before it transferred nothing and claimed success.
Also stops the bulk-sync catch from guessing at a cause. It logged every
failure as "(permission denied?)", which is what sent this investigation after
a permissions problem for a query the provider simply would not accept. It now
names the exception class: a denied permission is a SecurityException, a
rejected query an IllegalArgumentException.
Note SmsProvider uses the same `LIMIT`-in-sortOrder pattern against
content://sms and works today, so it is left alone — but it is the same latent
hazard if that provider ever tightens validation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ownership
Closes the TODO left in do_activate: when the laptop hands session ownership
to another phone, the displaced phone is now told, instead of discovering it
whenever it next happened to make contact.
Wire contract, mirrored byte-for-byte on both sides (design doc §D4):
ty::PEER_HANDOFF = 0x4F
sub::HANDOFF_RELEASE = 0x01 BUSY = 0x02 CLAIM = 0x03
Additive on purpose. Both sides log-and-ignore an unknown frame type
(`post-IK frame type=0x.. ignored` on the phone), so a peer without this build
is unaffected — no version gate needed. 0x4F was the next free type after FRAG
(0x4E); ERROR sits at 0x7F.
Note the protobuf PeerHandoff added earlier is the SPEC-level description. The
live BLE/LAN transport does not use VortexMessage — it uses the u8-typed Frame
format — so the implementation needed this frame type, not the oneof. The two
now describe the same thing at their respective layers.
The kind rides as the first payload byte rather than in Frame.sub: the laptop's
generic sealed-frame writer only takes a frame type, and reusing it beat
threading a sub parameter through every feature's send path. Both sides agree
on that placement; an empty payload is treated as malformed rather than
defaulted to a kind.
Sending needs the live session's cipher state, so it goes through the BLE
sealed writer, now published as BLE_SEALED_WRITER for command handlers outside
the BLE loop. That writer targets whichever peer is CURRENTLY connected, which
happens to be exactly right here: at the moment a switch is confirmed the live
link is still the peer being displaced, because we have not connected to the
replacement yet. That is load-bearing and easy to break later, so it is
documented at both the holder and the send site.
Delivery is best-effort by nature — it only lands while a link to the displaced
peer is still up. When it cannot be sent the peer falls back to learning on next
contact, which is exactly the old behaviour, so a failure logs at debug rather
than surfacing as an error.
Phone side, on RELEASE: clear activePeerPub, abandon any seek in flight (the
laptop already chose someone), and kick the presence loop so it goes back on air
immediately rather than up to ACTIVE_RECHECK_MS later. Without that last step a
released phone would sit silently attached to a laptop that has moved on,
invisible to every other laptop until the link happened to drop.
Still missing for §D4: the mirror direction. A phone-initiated switch does not
yet send RELEASE to the laptop it leaves, so that laptop can still show a stale
"Connected" until next contact — the laptop needs a dedicated raw-frame channel
to receive on, since the existing generic one has a single consumer. BUSY and
CLAIM are defined and logged on receipt but nothing sends them.
Compiles on both sides. Not runtime-verified: exercising a handoff needs two
paired devices.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes §D4. A phone-initiated switch now tells the laptop it left, so that laptop drops ownership immediately instead of showing a stale "Connected" until whenever it next made contact. Phone: sendPeerHandoffEncrypted() on the existing per-peer sealed-send helper, called from the reconnect listener's seek-satisfied branch. Ordering is load-bearing and commented as such — the RELEASE goes out BEFORE stopSeeking() tears anything down, while the old laptop's session is still registered and can carry the frame. Laptop: the generic additive-frame channel is single-consumer and notes/todos already owned it, so a second additive feature had nowhere to listen. Rather than add a 21st parameter to run_listener — which that channel's own doc comment exists to avoid — peer_handoff.rs now owns the channel, takes the frames it handles, and forwards everything else to notes untouched. PEER_HANDOFF also had to join the listener's frame allowlist, or it would have been warned-and-dropped before ever reaching the generic path. The channel now carries (peer_pub, frame_ty, payload) instead of (frame_ty, payload). A frame's meaning can depend on WHO sent it: "you are no longer my active peer" is unactionable without knowing whose statement it is, and inferring it from "whoever is active right now" would be a guess that is wrong exactly when it matters. Notes ignores the identity — one shared list, so the sender does not change the merge — and says so. On RELEASE the laptop drops ownership via arbiter::release, purges the pages showing that phone's data, and re-emits peers so the card's `active` flag clears. Trust is deliberately untouched: the phone still trusts us and may come back; forgetting is a separate, user-driven act. sanitize_peer_name is now pub in the daemon so the successor name — peer- supplied text reaching a UI — goes through the same sanitiser as the pairing path, rather than growing a second copy that would drift from it. Known gap, both directions: RELEASE rides the BLE sealed path 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 — the pre-existing behaviour, so no regression, but not yet the full promise. Verified: cargo test 141 passed, Android unit tests pass, both sides build, and the laptop app runs and reconnects normally on the reworked channel. NOT verified: an actual handoff (needs two paired devices), and the notes fan-out — notes.json is `[]` here and notes only logs when a merge changes something, so its silence confirms nothing either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removes the last SQL `LIMIT`/`OFFSET` smuggled through a `sortOrder` argument,
so both companion providers now use the same safe shape. `content://sms`
tolerates the clause today — unlike the call-log provider, which rejects it
outright (`Invalid token LIMIT`, fixed in the previous commit) — so this is
prophylactic rather than a live bug.
It is worth doing anyway because the failure here would be worse than the call
log's. CallLogProvider always had a Kotlin `cap` guard, so its SQL clause was
redundant and the reads merely returned nothing. querySms had NO cap: callers
depend on the window for correctness, not just for size. If `content://sms`
ever tightened validation the way the call-log provider has:
* loadThread pages the laptop's infinite scroll with LIMIT/OFFSET, so it
would return whole threads per page and the paging would break;
* readHistorySince would ship the entire message store in one batch instead
of a self-paginating slice — and oversized history is exactly what desyncs
the Noise cipher, per the note already on that function.
So the window moves into querySms: drop `skip` rows, then take `cap`. `cap` is
a required parameter rather than a defaulted one, so the compiler — not a
reviewer — guarantees no call site silently loses its bound. readSms keeps its
own existing guard; only its now-redundant SQL clause is gone.
Cost: `skip` becomes a cursor walk instead of a SQL OFFSET, so deep paging is
O(offset). Thread pages cap at 200, so that is a walk over a lazily-filled
cursor window rather than per-row I/O. Recorded in the KDoc so the tradeoff
isn't rediscovered later.
Verified on device: all six bulk-sync datasets still report "match", including
`sms` and `sms_ids`. That is a content-hash comparison, so an off-by-one in the
new windowing would have shown up as a mismatch and a resend rather than a
match. Not exercised: loadThread's paging, which needs the laptop's Messages
page and a scroll.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two documents for work that was previously only legible from commit messages
and scattered code comments.
docs/communication/README.md — how a phone and laptop find each other,
authenticate and exchange data, as the code actually does it. Six diagrams:
role asymmetry, the advertisement payload, the phone's advertising state
machine, the channel stack and frame format, pairing, reconnect/steady state,
and the ownership handoff.
Written against the source rather than from memory, which corrected several
things worth having written down:
* the trusted-runtime mDNS service is `_vortex._tcp.local.`; the
`_vortex-pair._tcp.` name applies only during a pairing window;
* "Audio Signal" is the general sealed app-data channel — notifications,
clipboard, SMS, notes, file offers, handoff — not audio. The name is
historical and misreads on first contact with the code;
* `shared/proto/vortex.proto` is spec-level. The live BLE/LAN transport does
NOT use `VortexMessage`; it uses the u8-typed Frame registry. Added as a
callout because acting on the wrong one is an easy mistake to make;
* unknown frame types are logged and ignored on both sides, which is the
property that makes new frame types additive with no version gate.
docs/design/multi-peer.md — the design behind one phone ↔ many laptops and one
laptop ↔ many phones, with the alternatives that were rejected and why. Most
of it is now implemented; it stays because the reasoning is the part that does
not survive in the code:
* why per-peer presence tokens beat one shared device key (revocation makes a
forgotten peer's token uncomputable, not merely rejected);
* why "seeking" must not become an advertising flag bit (is_well_formed would
have every deployed peer reject it as malformed);
* why a switch seeks BEFORE releasing, which is what removes the need for any
reconnect-suppression window;
* why Dark keeps advertising rather than going silent, pinned to the
proximity confirmation-scan constants that make silence unsafe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…t of it
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…vice
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This suits of commits is a long stretch to fix all the multiple peering issues with the current main branch.
I've tested them in all possible cases:
The issue with the current main branch are numerous, with zombie link preventing recapture of the existing pairing, BT/WIFI link state mismatch, missing interfaces...