[SUPERSEDED by revert #21484 / RE-LAND BASE] fix(android): close retention-probe audio-loss windows and make the escalated backoff reachable
Summary
Closes the review debt of #21404: three of the four P1 threads left unanswered at merge were verified REAL against current origin/main by an independent line-level trace, and a second independent model audit (gpt-6-astra, unseeded) concurred on all four verdicts. The fourth P1 was REFUTED as filed, and this PR still hardens the exact boundary it pointed at. No audio-loss path ships in the 1.0.55x builds, and the escalated reconnect schedule this PR series advertised becomes actually reachable.
-
Subscribe before probing (P1, real).
fireDeviceReadyran the retention probe before the audio subscription.readCharacteristicenqueues synchronously into the serial GATT command queue whilesubscribeCharacteristicdefers through the main looper, so the probe read could own the queue first; the firmware drops dequeued frames while connected-but-unsubscribed, and a lost read callback (the queue has no timeout for reads) blocked the CCCD write indefinitely. The subscription now runs first and the probe read is posted behind it on the same looper, so it can never precede the CCCD write. -
Require a valid RTC before certifying retention (P1, real). The firmware offline writer drops every request while its clock is unsynced (
sd_card.c process_write_data_req), butretentionProbeSucceedsdiscarded the status payload's rtc-valid field, so a mounted-but-timeless device (rebooted while Flutter absent, no persisted epoch) was certified as retaining audio it was actually throwing away. RTC validity is now required; RTC alone still does not license escalation. -
Preserve a completed proof across disconnected retries; invalidate on each new GATT connection (P1, real).
RetentionProbeLifecycle.interrupt()erased completed verification at every disconnect and retry attempt, always BEFOREhandleRetryLogicread it, sodelayForFailurealways sawretentionVerified=falseand the advertised 6/12/30/60s schedule was unreachable (flat 3s forever). The real hazard ran the other way: the schedule was dead, and the naive fix (keep proof forever) would strand offline audio behind long gaps on devices whose storage later broke. Nowinterrupt()iscancel()(retires an in-flight probe, keeps completed proof), and the proof resets inonGattConnectedbefore anything on the new link can consume stale verification. -
Stale proof on a new GATT (P1, refuted as filed, hardened anyway). The disconnect path already retired the in-flight probe before retry selection, so the filed window (consume stale proof on a pre-ready disconnect) could not occur as described. The connect-time invalidation in (3) re-establishes the same guarantee structurally: any proof describes exactly the GATT link it was proven on.
Product invariants
- INV-CAP-1 (pendant-audio-retention): every change narrows when the phone may stretch reconnect gaps (RTC gate, per-link proof) and removes two ways the audio subscription could be delayed behind optional probe work. Regression tests pin subscribe-before-probe, the deferred probe enqueue, cancel-vs-reset semantics, and connect-time invalidation.
Failure class
Failure-Class: none
Test plan
-
BleReconnectBackoffTest: RTC-less payloads with nonzero storage stay flat; escalation requires rtc=1; overflow cases keep rtc=1 -
RetentionProbeLifecycleTest: completed proof survivescancel(); fresh connection resets it; late/replaced-GATT callbacks still rejected -
RetentionCaptureOrderingContractTest(new): reads the real service source and pins fireDeviceReady ordering, the deferred probe enqueue, cancel-vs-reset, and connect-time invalidation - Android JVM unit suite (CI
android-unit-tests) - Full CI green on this exact head, then independent review before merge