fix(ble): native owns the radio, the send's address and pairing; the frontend renders - #1021
Merged
Merged
Conversation
Advertising follows the device's identity, and native owns it. The BLE service advertises whenever the device has an identity, derived again on its own worker thread when the service starts or binds, when the activity resumes, once genesis or init has produced the identity, when a Bluetooth permission is granted and when the adapter turns on. The GATT server reads the identity characteristic from Rust when a peer reads it. The frontend sends no radio request. offlineSend is one wallet.sendOffline call; it used to set the advertised identity, start advertising and scanning, and sleep 1.5 s first, swallowing every failure, and re-advertise when the send finished. The contacts provider started advertising on identity readiness and on each learned address, and the wallet screen started and stopped it on mount, visibility and unmount, so a device on any other screen could not be reached. Deleted: the four BLE host controls (NativeHostRequestKind 4-7 reserved), the BLUETOOTH_PERMISSIONS host event (2 reserved), BridgeRpcRequest ble_identity (10 reserved) with BleIdentityPayload and the setBleIdentityForAdvertising RPC, stopBlePairingAdvertise, the caller-less BleCoordinator.ensureBleReady, the GATT host's pushed identity value, and PairingTestActivity, a start/stop advertising panel in the production manifest. The pairing loop stops only its scan. Tests: the offline send, the wallet screen's lifecycle and the contacts provider make no radio request; six mutation controls, each red on its named test. The native lifecycle is compile-checked; a device run shows it.
The GATT service carried a relationship-status characteristic answering any connected peer with this device's send status for the contact at that address, and a client read of it ran from the frontend's readPeerRelationshipStatus through a bridge RPC and a blocking coordinator call. Nothing called the frontend function, so the server served a value no client read. Deleted: the characteristic and its UUID, the client read with its event and pending-read slot, the bridge RPC and its Kotlin arm, the JNI export and BleRelationshipStatusCharValue. Also BleCoordinator.readPeerIdentity, which answered true whatever happened beside a "for now" comment and had no caller, and setSessionMode with BleSessionMode: nothing set the mode, so the scanner's was always idle and read only by a log line. A relationship's send status is where Rust already reports it, on the contact list. Gate control: the bridge-name gate refuses the Kotlin arm left behind, naming it.
…where it goes The frontend chose where an offline send went. The send form resolved the recipient's BLE address (the contact's, else a cache of its own, else a bridge round trip to a native map) and refused a contact it found none for before Rust was asked. offlineSend sent the address inside a BilateralPrepareRequest, the device-to-device prepare, whose intent fields (11-14) existed only for this route and whose ble_address (6) no receiver read; the route also took a caller-authored operation in place of authoring one. The contacts mapper filled a missing address from the frontend's cache and dropped one that was not MAC-shaped. The prepare builder looked an address up only to fill field 6, and when the contact had none wrote the session's address into the contact, the mark that ends the pairing loop, outside the pairing confirm. wallet.sendOffline takes OfflineTransferRequest: counterparty, token, amount and memo, as the user gave them. The SDK resolves the address (bluetooth::peer_address): the one the contact holds, else the one its identity, checked against the contact's genesis, was seen at this session; a phone it has not met is refused, saying so. It authors the operation from the intent. BilateralPrepareRequest 6 and 11-14 are reserved. The session map moved out of jni::state into a host-compiled, tested module. Deleted: the frontend resolver module with its cache and normalizer, the resolveBleAddressForDeviceId RPC with its Kotlin arm, wrapper and JNI export, recordPeerIdentity (unverified, and nothing called it) and resolve_ble_address (nothing called it). Tests: the resolver's order, the route refusing then passing on the contact's address, the request's exact bytes, the form naming no address, the mapper carrying Rust's address as-is. Six mutation controls, each red on its named test.
…runs it The contacts screen ran BLE pairing: it started the loop when it counted more unpaired contacts than before and stopped it when it unmounted, so pairing ran only while that screen was open. Pairing now follows the session, as the lock does. Rust starts the loop when the facts Kotlin already reports say the app is in the foreground with Bluetooth on and permitted and there is an identity to pair as (SessionManager::pairing_may_run), and stops it when they say otherwise; the loop still ends by itself once no contact is unpaired. Adding a contact wakes or starts the loop when the session lets it run. Kotlin creates the BLE coordinator the loop drives when the identity's BLE service starts, before those facts go out, as the old start arm did. The scanning hint no longer asks to keep both devices on this screen. Deleted: the screen's start and stop, the startPairingAll and stopPairingAll RPCs with their Kotlin arms, wrappers and JNI exports, and the hasUnpairedContacts chain down to its query, which answered false when the database could not be read and which nothing called. Tests: the session's pairing decision; the contacts screen asks for nothing but reads. Three mutation controls, each red on its named test. The Android start and stop are compile-checked; a device run shows them.
A BLE transaction error frame ended the send in flight as failed. Kotlin raises that frame for any failed connection to any address (a failed identity read, a failed GATT connect, a failed connection-state call), so a pairing attempt with another phone could fail a send, and a lost link, which is liveness, was reported as a failed transfer while the step stayed open. The listener is deleted: the send ends on Rust's word, its events or its pending list, and a screen that stops waiting reports the step open. Test: a transport error frame mid-send leaves the send waiting, and Rust's completion finishes it. Mutation control: the listener restored turns it red.
…re pairing stands Both pairing completions marked the session Complete, and told the screen the contact was paired, when storing its address failed. The loop never revisits a Complete session, so such a contact stayed unpaired until the process restarted, among them a phone paired before it was added as a contact, whose store fails for want of the contact. The loop reported only "scanning", so the contacts screen inferred progress from raw radio events about any phone: "Peer Found" on any DSM advertisement, and "Paired!" when a phone's identity was read, before pairing had completed. A session now completes only once the address is stored; a failed store fails the session and the loop retries it. The loop reports each transition, and contacts.list states each contact's phase from the loop's sessions (ContactAddResponse.pairing, ContactPairingPhase: paired, idle, searching, connected, retrying; the frontend refuses a phase the wire does not name). The screen's line renders those phases and no longer listens to raw radio events; a pairing event re-reads the list. A dropped link no longer sets the session's state twice. The orchestrator's database tests set their own storage directory; they passed only when an earlier test had set one. Tests: both completions refuse an unstored address, the phase mapping, the list route over two devices (idle, searching, paired), the strict phase in the frontend, the line. Seven mutation controls, each red on its named test.
…or the wallet screen
DSM runs on devices, and offline goes appliance to appliance. The BLE
work said "phone" in comments, test names, the offline send's refusal
("the devices have not met over BLE" now), the pairing hint and the
conformance rows.
The send screen's help told both people to stay on the wallet screen. That
screen no longer runs the radio: the device advertises while it has an
identity, and the incoming-transfer prompt is mounted for the whole app.
The help now says the recipient accepts in the app.
…ess bytes to its receiver Recorded open in §6.29. The receiver never checks that a prepare is for it. When the sender is also its contact, the tip mismatch stores the sender's claimed tip as a live-peer claim, which blocks this device's sends to the sender, online as well as offline, and nothing outside recovery clears it. A throwaway probe reproduced it. The prepare's target field is not under the sender's signature; the operation in the signed commitment names the recipient.
Offline goes appliance to appliance: pairing, advertising and the radio
are the hardware side, and the text about them says "appliance", including
the offline refusal ("the appliances have not met over BLE"), the send
help, the pairing hint and the conformance rows. Online is an account that
can be on any device, so nothing here describes it in device terms.
Identifiers such as device_id keep their names.
…exist; the name gate reads androidTest #1012 deleted five bridge arms the frontend no longer sent (getDeviceIdBin, getGenesisHashBin, getSigningPublicKeyBin, getPersistedGenesisEnvelope, getWalletHistoryStrict). AndroidLayerProofTest still called them, so nine of its tests got the unknown-method answer and the managed-device job has been red on main since that merge. No gate read androidTest. The proof now reads what the frontend reads: identity decoded from the transport headers, history through the wallet.history route, framing and concurrency over getTransportHeadersV3Bin; t50 also requires every thread to read the same device id. The tests of the deleted arms are removed. claimFaucet had been returning before it claimed; the emulator tests that called it read without it, and the real-hardware test sends faucet.claim as the frontend does and requires it to land. ci/bridge_rpc_names.py now requires every bridge name the instrumented suite sends to be one Kotlin handles, and the unknown-method probe's name to be one it does not.
…used route fails with Rust's reason; the name gate reads every send
Review of the previous commit found four defects in it.
- The deleted arms were the suite's only route to the JNI identity exports
Unified.getDeviceIdBin/getGenesisHashBin, which BLE still reads (the GATT
identity characteristic, the advertising gate). t22 now calls them
directly and requires them to equal the device id and genesis hash in the
transport headers the frontend reads. No test had compared the two.
- claimFaucet asserted OK_BYTES with no message, so a refusal lost Rust's
reason, and t40's success check could never fail (the handler answers a
FaucetClaimResponse only on success). A refused route now fails with
Rust's reason, and t40 requires a release.
- The gate missed t31, which encoded its method field by hand; t31 now goes
through the one encoder, and the gate also reads a hand-encoded method
field. The probe rule missed a typed declaration and passed with no
probe; the probe must now be readable, sent by the suite, and unhandled.
- The §6.29 row said t42 compared the headers with getDeviceIdBin. It never
compared anything; the row now says so.
Run on the emulator, t40 is refused because ensureGenesis installs the
loopback test config ("member instrumented-node-1 has a register_incarnation
that is not Base32-Crockford"). It cannot pass on any device until the suite
installs a config naming the pinned set; recorded Open.
This was referenced Sep 26, 2026
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.
The frontend made BLE decisions: when to advertise and scan, which identity to broadcast, which address a send goes to, when pairing runs and how far it has got. Each is now native's: Rust decides, Kotlin carries it out, the frontend sends the user's intent and renders what Rust reports. Six commits of changes, each with its CONFORMANCE_GAPS §6.29 row, tests and mutation controls, then a wording commit, a recorded finding, and a fix for the managed-device job that has been red on
mainsince #1012.What changed
The radio is native's. The BLE service advertises whenever the device has an identity, re-derived on its own thread when the service starts or binds, the activity resumes, init or genesis produces the identity, a permission is granted or the adapter turns on. The GATT server reads its identity characteristic from Rust at read time. The frontend's priming (identity relay, advertise and scan requests, a 1.5 s sleep, all failures swallowed) and the wallet screen's advertising lifecycle are gone.
NativeHostRequestKind4–7,NativeHostEventKind2 andBridgeRpcRequest10 are reserved;PairingTestActivity(start/stop advertising buttons in the production manifest) is deleted.Dead BLE surface. The relationship-status GATT characteristic, which nothing read, is deleted end to end (client, server, JNI, proto). So are
BleCoordinator.readPeerIdentity(returnedtruewhatever happened, beside a "for now" comment) and the unused session mode.An offline send carries intent only.
wallet.sendOfflinetakesOfflineTransferRequest(counterparty, token, amount, memo). The SDK resolves the address inbluetooth::peer_address: the contact's stored address, else this session's verified sighting. The frontend resolver, its cache and theresolveBleAddressForDeviceIdchain are deleted, andBilateralPrepareRequest6 and 11–14, which only this route read, are reserved. The prepare builder no longer writes a session address into the contact, which is the mark that ends pairing, outside the pairing confirm.Pairing follows the session. Rust starts the pairing loop when the hardware facts Kotlin already reports say foreground, Bluetooth on and permitted, and an identity exists. It stops the loop when they say otherwise, and adding a contact wakes it. The contacts screen's start and stop, the
startPairingAll/stopPairingAllRPCs and thehasUnpairedContactschain (which answeredfalseon a database error) are deleted.A BLE transport error fails no send. Kotlin raises that frame for any failed connection to any address, and
offlineSendtreated each one as its own failure."Paired" means the address is stored. Both pairing completions used to mark the session Complete when storing the address failed, and the loop never revisits a Complete session. The loop now reports each transition, and
contacts.liststates each contact's phase (ContactPairingPhase). The screen's status line renders it, where it used to infer progress from raw radio events and show "Paired!" when an identity was read.The offline side is the appliance. Pairing, advertising and the radio are the hardware side, and the text about them says "appliance": comments, test names, the offline refusal ("the appliances have not met over BLE"), the send help and the pairing hint. Online is an account that can be on any device, so nothing describes it in device terms. The send help no longer asks both people to stay on the wallet screen: that screen no longer runs the radio, and the incoming-transfer prompt is mounted for the whole app.
The instrumented proof calls only bridge methods that exist. feat(ci): the bridge's two sides must agree on every RPC name #1012 deleted five bridge arms the frontend no longer sent (
getDeviceIdBin,getGenesisHashBin,getSigningPublicKeyBin,getPersistedGenesisEnvelope,getWalletHistoryStrict).AndroidLayerProofTeststill called them, so nine of its tests got the unknown-method answer andAndroid Instrumented Tests (managed device)has been red onmainfrom that merge on (green at fix(sofi): the verdict is Core's — facts built from reads, the ladder inside the advance #1011). The proof now reads what the frontend reads: identity decoded from the transport headers, history throughwallet.history, framing and concurrency overgetTransportHeadersV3Bin. t22 calls the JNI identity exports BLE reads (Unified.getDeviceIdBin/getGenesisHashBin) and requires them to equal the headers' identity, a comparison no test made before. A routed call Rust refuses fails with Rust's reason.ci/bridge_rpc_names.pynow reads androidTest: every name the instrumented suite sends (through the encoders or a hand-encoded method field) must be one Kotlin handles, and the unknown-method probe must be readable, sent, and unhandled. A read-only adversarial review of the first version found four defects in it;f2a97e5c2fixes them.Device run owed
These are compile-checked Android paths that no host test drives:
Verification (at
394c6fcfe; the later wording and docs commits re-ran jest, build, the gates, Rust fmt and the renamed tests)cargo fmt --check,make lint, production safety checks,cargo ndk … check --features=jni,bluetooth: pass--release)peer_address,send_offline_tests,bilateral_envelope, the prepare tests inbilateral_ble_handler,session_manager,pairing_orchestrator,contacts_routes(incl. the two-device list test): passf2a97e5c2)main's test (the five deleted names at every call site;main's gate passes it), a hand-encoded method field naming a method Kotlin lacks, a typed probe bound to a handled method, an unreadable probe, a t60 that sends a literal, no probe.The full
--workspaceboard is CI's; the 45 tests in the ERA expected-red manifest (#1019) stay red on it. On this PR's run before the instrumented fix,Rust tests (dsm_sdk)failed exactly those 45 and no others.Still open (recorded in §6.29)
A prepare for another relationship is not meaningless bytes to its receiver. The receiver never checks that the prepare is for it. When the sender is also its contact, the tip mismatch stores the sender's claimed tip as a live-peer claim, which blocks that device's sends to the sender, online as well as offline, and nothing outside recovery clears it. A throwaway probe reproduced it.
BleCoordinator.resolveSessionroutes a transfer to the one ready peer when neither address nor identity matches. The fix needs the counterparty's device id carried with the chunks, and a device to verify it.The accept follow-up re-reads await the device run already recorded.
Reconciliation frame remnants, the unused
removeContact/hasContactForDeviceIdwrappers, the contacts screen's 5 s poll, and the exportedPicoSelfTestActivitybelong to other passes.