fix(app): keep a live native BLE link from showing as Disconnected
Summary
Closes the ways the app can show a pendant as Disconnected while native BLE holds a live, ready link. In that state, a pendant that is visibly connected in iOS Settings (blue light) never streams audio until the app is killed.
Root cause (observed on a real iPhone, prod 1.0.563+1377). The app opened at 22:42:30 and sent one connect request at 22:42:34. Dart's launch connect gave up at its 60s deadline. The pendant arrived at 22:43:54, 80s after the request, and native finished service discovery at 22:43:56. After that the app process sent zero GATT reads, writes, or notify subscriptions until it was restarted. The only traffic was the native 10s RSSI poll, which runs only while peripheral.state == .connected. This is the pre-#21302 path: the timeout tore the connection down and disposed its transport. That dispose unregistered the peripheral from BleBridge, but NativeBleTransport.disconnect() returned early because Dart's mirror already said disconnected, so it never sent unmanageDevice. Native kept connecting and delivered device-ready to nobody. #21302 (merged 2026-10-13 14:45 UTC) keeps the transport on timeout. A new provider-level test reproduces the report: it fails on ce3826d2fd^ and passes on main.
This PR removes the footguns that made that path possible and still leave sibling paths open on main:
- Owned bridge registration.
BleBridge.registerPeripheralnow returns a disposer that unregisters only while the caller still owns the peripheral. Before, unregistration was by id alone, so a retired transport disposed after its replacement registered would silence the replacement's device-ready. - Disconnect releases native based on intent, not the mirror.
NativeBleTransport.disconnect()now callsunmanageDeviceunless this transport already released native. Before, it skipped the call whenever Dart's mirrored state said disconnected, even though native keeps connecting after a Dart-side timeout. A device-ready already in flight after release is ignored, so it cannot re-adopt the link behind an explicit disconnect. DeviceService.disconnectDevicedisposes the transport it drops, matching_teardownConnection. Before, the dropped transport stayed registered and could swallow the next device-ready: tap Disconnect while native is reconnecting, native reconnects, and the ready lands on an orphan whose connection no longer listens.- Foreground reconcile. A new
NativeLinkReconciler, bound inmain.dart, runs on app resume. If the provider shows the paired device disconnected but native reports its peripheral connected, it re-checks that the same id is still paired and still disconnected after the async probe. It then callsDeviceService.ensureConnection(id, force: true)for exactly that id and publishes the result throughDeviceProvider.onDeviceConnectionStateChanged, the entry point native events use. That covers both a missing transport and aDeviceServiceconnection the provider never heard about. Native answersmanageDevicefor a connected peripheral by replaying device-ready (iOSreplayReady/hydrateReady, AndroidadoptConnectedDevice), so this does not touch the radio and never starts a physical connect. It covers any future lost-ready path, not one cause.
Also:
- If
unmanageDevicefails, the transport stays unreleased, so the release can be retried and native readies are still adopted. - Every transport's
_updateStateignores events afterdispose()instead of throwing on a closed controller. DeviceProvider._handleDeviceConnectedadmits each physical connect once, keyed on(connection, connectionEpoch)and cleared whenever the mirror disconnects. Overlapping connected events (a native connect still initializing plus a resume reconcile) no longer run capture start, battery listeners or WAL attach twice.
Line-Count-Exception: app/lib/providers/device_provider.dart | 1488 -> 1576 | the per-connect admission guard and the readoption pairing constraint (armed admission, gated persists and setup continuation) live in the provider's own connect entry point; the reconcile logic itself is split into native_link_reconciler.dart
UI-Evidence: none -- no visible UI change; main.dart only binds the device provider app-lifecycle listener
Verification
flutter test test/providers/device_provider_late_ready_test.dart: 14 tests. Each invariant was mutation-checked: reverting any one of the ten fixes (owner-scoped bridge release, intent-based disconnect, the ready guard after release, dispose indisconnectDevice, the reconcile native probe, the held-link republish, the reconcile id re-check, retryable failed release, the per-connect admission guard, and the admission reset on any mirror disconnect) fails exactly its test. The late-ready provider test also fails once3826d2fd^.- Independent review: codex
gpt-6.1-solvia agentctl (read-only brief). Five rounds viaagentctl continueon one session, each re-reviewing the delta. Rounds 1โ4 found eight P2 defects in earlier revisions, all fixed and pinned by tests above: the reconcile didn't republish a linkDeviceServicealready held; the probed id could differ from the connected id; a failed release was recorded as released; synthetic publishes across awaits (double admission, overriding a real disconnect, publishing a re-paired device); overlapping admissions for one physical connect; and an admission latch surviving provider-only disconnects. Round 5: "Nothing blocks merge in this delta." bash app/test.sh(full hermetic suite + spine) with Flutter 3.44.5 on macOS: spine 138/138; suite 5,500 passed, 21 skipped, 4 failed. The four failures (apps_category_section_large_text_testร2,ai_app_generator_large_text_test,settings_search_header_stability_test) fail identically on unmodifiedorigin/mainand are fixed by the open #21425. That PR also fixesdevice_service_connection_failure_test, which this PR leaves alone to avoid a conflict.- Changed-file
flutter analyze: no new issues (two pre-existing infos).
Not verified on device: the resume reconcile has not been exercised on a physical phone. The incident evidence (bluetoothd/runningboard logs) comes from the reporting iPhone.
Product invariants affected
- INV-DATA-1 โ production data-plane routing is unchanged; only the device provider's construction in
main.dartchanges.
Failure class
Failure-Class: FC-superseded-connection-mutates-live-session
๐ค Generated with Claude Code