[SUPERSEDED by revert #21484 / RE-LAND BASE] fix(android): close retention-probe audio-loss windows and make the escalated backoff reachable

Closed
GI
1d ago
+323-33

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.

  1. Subscribe before probing (P1, real). fireDeviceReady ran the retention probe before the audio subscription. readCharacteristic enqueues synchronously into the serial GATT command queue while subscribeCharacteristic defers 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.

  2. 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), but retentionProbeSucceeds discarded 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.

  3. 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 BEFORE handleRetryLogic read it, so delayForFailure always saw retentionVerified=false and 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. Now interrupt() is cancel() (retires an in-flight probe, keeps completed proof), and the proof resets in onGattConnected before anything on the new link can consume stale verification.

  4. 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 survives cancel(); 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