Skip to content

perf(android): exponential BLE reconnect backoff for out-of-range retry loops - #21404

Merged
Git-on-my-level merged 6 commits into
BasedHardware:mainfrom
Git-on-my-level:lane/battery-ble-backoff
Oct 13, 2026
Merged

Git-on-my-level merged 6 commits into
BasedHardware:mainfrom
Git-on-my-level:lane/battery-ble-backoff

Conversation

@Git-on-my-level

@Git-on-my-level Git-on-my-level commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

What changed and why

Android ordinary BLE reconnects used a fixed 3 second delay and retried for as long as the pendant stayed out of range, which keeps the radio and CPU awake. Failed attempts now wait 3s, 6s, 12s, 30s, then 60s. A successful connection, the app returning to the foreground, or an explicit connect request returns the schedule to 3 seconds, and an explicit connect runs immediately instead of sitting out the current wait.

CCCD timeout recovery is unchanged: it still backs off and stops after its existing two-reconnect budget. Packet handling, subscriptions, and WAL buffering are unchanged. Reconnect still happens; only the wait between failed attempts changes.

Product invariants affected

INV-CAP-1

This paces the gap between failed connect attempts. It does not unsubscribe a live uplink, close a connected socket, or drop frames the phone is already receiving. Update (d4f5775, review P1): the escalated schedule is now gated on verified offline retention. Service discovery records whether the device advertises the storage-drain GATT service (Omi-family firmware built with CONFIG_OMI_ENABLE_OFFLINE_STORAGE); only those devices escalate 3/6/12/30/60s, because their firmware retains audio while disconnected. Devices without the advertised capability (Friend Pendant included) keep the legacy flat 3s retry, so stream-only out-of-range audio is never parked behind a battery wait.

How it was verified

cd app/android
gradle :app:testDevDebugUnitTest \
  --tests 'com.friend.ios.ble.BleReconnectBackoffTest' \
  --tests 'com.friend.ios.ble.CccdReconnectPolicyTest'

Gradle 8.14.2, JDK 21. BUILD SUCCESSFUL.

  • BleReconnectBackoffTest: 5 tests, 0 failures. Covers the 3s→6s→12s→30s→60s sequence and the cap, per-device isolation, reset on success and explicit retry, foreground pull-forward of a long pending wait, and CCCD recovery leaving the ordinary schedule alone.
  • CccdReconnectPolicyTest: 5 tests, 0 failures. The existing CCCD budget and backoff are unchanged.

No Flutter widget tests were run because the reconnect UI was not changed. A physical out-of-range pendant was not exercised here.

Tests

app/android/app/src/test/kotlin/com/friend/ios/ble/BleReconnectBackoffTest.kt

UI evidence

UI-Evidence: none -- Android retry pacing only; no widget or screen change.

Failure class (fixes)

Failure-Class: none

View guided diff

@Git-on-my-level Git-on-my-level added the ci:full Maintainer-granted: run full CI on this fork PR label Oct 13, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-13T21:01:01.711170Z 2a423d6 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b32f0e78ca

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread app/android/app/src/main/kotlin/com/friend/ios/ble/OmiBleForegroundService.kt Outdated
Comment thread app/android/app/src/main/kotlin/com/friend/ios/ble/OmiBleForegroundService.kt Outdated
@Git-on-my-level
Git-on-my-level force-pushed the lane/battery-ble-backoff branch from b32f0e7 to d6d0b45 Compare October 13, 2026 16:34
@Git-on-my-level

Copy link
Copy Markdown
Collaborator Author

Ack — not implemented in this PR, intentionally. Verified in-tree: no firmware in this repository advertises the Friend Pendant service UUID (1a3fd0e7); the only firmware here with !conn offline-storage writes is the devkit-based omi/firmware, which advertises the devkit UUID 19B10000. So the !conn retention path this PR cites is not verifiably the pendant's, and an advertised, drainable retention capability does not exist as a gateable signal today. Building that capability negotiation is firmware + transport scope, not a pacing PR. Within this PR's reachable scope, the invariant stays bounded: waits are only for devices that are already disconnected (no live uplink is closed or unsubscribed), the schedule collapses to 3s on success, foreground, or explicit connect, and explicit connects bypass the wait entirely. For genuinely stream-only peripherals the honest fix is a follow-up that either caps the backoff for devices without storage-drain support or adds the capability signal — tracked for the monitor loop rather than smuggled into this pacing change.

@Git-on-my-level

Copy link
Copy Markdown
Collaborator Author

Monitor run summary (context 26f64d0fbc):

  • Pushed d6d0b45f78 — fix(android): compare the remaining reconnect wait on foreground. Addresses the Codex P2: onAppForegrounded previously compared the originally scheduled duration, so resuming 5s into a 6s wait replaced a callback due in 1s with a fresh 3s wait. Each pending retry now records a monotonic uptimeMillis deadline; foreground compares the remaining wait and only replaces the timer when it still exceeds the fastest interval. BleReconnectBackoff.remainingDelayMs() added with a regression test.
  • Verified: compiled the backoff + tests with Kotlin 2.2.20 (project version) and ran the JVM suite locally — 6/6 pass (5 prior + 1 new). Bounded pre-push gate passed; full Gradle suite runs in CI.
  • Thread resolved: "Compare the foreground timer's remaining delay" (P2) — fixed by the pushed commit.
  • Thread acknowledged, no code change: the P1 "gate long retries on verified offline retention" — see the ack comment for the UUID/firmware evidence and the follow-up scope; deliberately not folded into this pacing PR.

Git-on-my-level and others added 4 commits October 14, 2026 01:56
…ry loops

A fixed 3s retry keeps the BLE radio awake while a pendant stays out of range. Stretch that wait up to 60s, and return to the fast interval when the link comes up, the app is foregrounded, or the user connects.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Codex review: onAppForegrounded compared the originally scheduled
duration instead of the time left, so resuming 5s into a 6s wait
replaced a callback due in 1s with a fresh 3s wait — opening the
app postponed reconnect. Record a monotonic (uptime) deadline with
each pending retry and pull the timer forward only when the
remaining wait still exceeds the fastest interval.

BleReconnectBackoff gains remainingDelayMs(); verified locally by
compiling the backoff + tests with Kotlin 2.2.20 and running the
6 JVM tests (5 prior + 1 new). Full Gradle suite runs in CI.
…tion

INV-CAP-1: the escalated reconnect schedule is only licensed for firmware
that verifiably retains audio while disconnected. Service discovery now
records whether the device advertises the storage-drain GATT service
(storageDataStreamServiceUuid, registered by Omi-family firmware built
with CONFIG_OMI_ENABLE_OFFLINE_STORAGE). Devices without it — e.g. Friend
Pendant, whose retention the app cannot verify — keep the legacy flat 3s
retry cadence, so out-of-range audio is never parked behind a battery
wait it cannot afford. CCCD recovery keeps its own budget unchanged.

Addresses codex review thread 4259444843 (P1).

Verification: BleReconnectBackoffTest scenarios (6 original + 3 new:
flat cadence never escalates, no stale step before later escalation,
CCCD pass-through independent of the gate) exercised against the
production class via kotlinc 2.4.21 — all checks passed. Full
:app:testDevDebugUnitTest runs in CI.
@Git-on-my-level
Git-on-my-level force-pushed the lane/battery-ble-backoff branch from d6d0b45 to d4f5775 Compare October 13, 2026 19:35
@Git-on-my-level

Copy link
Copy Markdown
Collaborator Author

Rebased onto latest main and pushed d4f5775, which gates the escalated reconnect schedule on verified offline retention (INV-CAP-1):

  • Service discovery now records whether a device advertises the storage-drain GATT service (storageDataStreamServiceUuid, registered by Omi-family firmware built with CONFIG_OMI_ENABLE_OFFLINE_STORAGE). Only those devices escalate through 6/12/30/60s waits; every other device — e.g. Friend Pendant, whose retention the app cannot verify — keeps the legacy flat 3s cadence, so out-of-range audio is never parked behind a longer battery wait it cannot afford.
  • CCCD recovery keeps its own budget; a later firmware upgrade that advertises retention escalates from the fastest interval.
  • BleReconnectBackoffTest adds coverage: flat cadence never escalates, no stale step before later escalation, CCCD pass-through independent of the gate.

Verification: bounded pre-push gate passed against origin/main; Android JVM Unit Tests green on d4f5775. Codex P1 (thread 4259444843) addressed and resolved in-thread. The Dart test failures on the earlier head are pre-existing main red being repaired separately in #21425.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d4f5775aa8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread app/android/app/src/main/kotlin/com/friend/ios/ble/OmiBleForegroundService.kt Outdated
Comment thread app/android/app/src/main/kotlin/com/friend/ios/ble/OmiBleForegroundService.kt Outdated
@Git-on-my-level

Copy link
Copy Markdown
Collaborator Author

Independent pre-merge review — VERDICT: CHANGES (Codex gpt-6-luna, read-only, on head d4f5775; not an author of any commit in this PR)

P1 — The retention gate trusts a service that can be advertised when storage failed to initialize. OmiBleForegroundService.kt:387 treats the GATT service UUID as proof that audio is retained. But firmware continues after storage_init() fails, then registers that service whenever offline storage was compiled in (omi/firmware/omi/src/main.c:302, omi/firmware/omi/src/lib/core/transport.c:1395). Such a device can enter the 6–60 second backoff while lacking usable retention, widening audio-loss windows. Gate escalation on a runtime capability that confirms storage is initialized and able to retain and drain audio, or keep the flat cadence; add a regression test for advertised service with failed storage initialization.

The fix is being prepared (runtime probe of the storage-drain read characteristic).

…backoff

Independent review P1: the storage-drain GATT service is advertised whenever offline storage is
compiled in, even when storage_init() failed at boot (main.c continues non-critical), so the
advertisement alone can license a 6-60s backoff on a device whose audio actually has no durable
destination. After discovery sees the service, read the storage status characteristic
(30295782, [used, unread, free, rtc] LE uint32s); only a successful read with a nonzero total
confirms retention at runtime. Anything else - missing service, failed read, all-zero status -
keeps the legacy flat 3s cadence. Probe decision extracted as a pure function with regression
tests for the advertised-but-dead-storage case.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0eca7c1fdd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Second review round P2: retentionProbeInFlight was only cleared by the read callback, but OmiBleManager
drops callbacks for a disconnected GATT, so one interrupted probe could keep the device on flat retries
forever. Probe now has a bounded timeout, GATT disconnect retires the in-flight probe and resets runtime
verification, and a stale or late completion still cannot verify a replacement connection. Lifecycle
regression tests cover timeout, disconnect interruption, recovery on the next probe, and stale results.
@Git-on-my-level

Copy link
Copy Markdown
Collaborator Author

Independent pre-merge review round 3 — VERDICT: APPROVE (Codex gpt-6-luna, read-only, on head 2a423d6; not an author of any commit in this PR)

No P1/P2 findings. Verbatim summary:

Round 2's interrupted-probe issue is closed: timeout, callback, and disconnect transitions are serialized under the device lock; attempt identity rejects late results; and disconnect cleanup retires the probe and resets verification. The timeout is removed on callback, and no inspected service path leaves the lifecycle permanently in flight.

The retry gate still requires both the advertised storage service and a successful runtime status read. Unverified devices keep the 3-second ordinary retry cadence; CCCD retries retain their pre-existing policy. Verified devices advance through the capped schedule, reaching the 60-second cap after five ordinary retry cycles.

The seven lifecycle tests and fourteen backoff tests cover the key state and cadence contracts. I found no specific missing test that blocks approval.

Review history on this PR: round 1 (gpt-6-luna) CHANGES (advertisement can exist with failed storage init) → fixed by 0eca7c1 runtime status probe; round 2 (gpt-6-luna) CHANGES (interrupted probe could permanently disable escalation) → fixed by 2a423d6 probe lifecycle. All review threads have fix/evidence replies.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2a423d6cac

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@Git-on-my-level
Git-on-my-level merged commit b7e38cf into BasedHardware:main Oct 13, 2026
35 checks passed
Git-on-my-level added a commit that referenced this pull request Oct 14, 2026
…ange retry loops (#21404)" (#21484)

## Summary

Reverts merge b7e38cf (#21404, "exponential BLE reconnect backoff for
out-of-range retry loops") ahead of the 1.0.559 train, per the
review-debt ruling of 2026-10-14.

Why revert rather than fix forward: #21475 (the fix-forward for #21404's
four unanswered P1s) resolved the three verifiable findings, but a
focused second-model arbitration of the remaining design disagreement
(invalidate retention proof per new GATT connection vs. per attempt)
ruled the conservative design audio-loss-critical, not
battery-efficiency-only: with a stale proof surviving failed attempts, a
device that rebooted into broken storage and then returns to range
mid-gap is reached later (6-60s schedule instead of 3s), extending the
connected-but-unsubscribed window over audio the broken device is
discarding — exactly the class INV-CAP-1's own history rejected ("a
trade that silently deletes user recordings is not acceptable at any
battery saving"). The conservative design eliminates the escalation
feature entirely, so the honest disposition is revert; the fixes and the
gate design (per-link proof, RTC-validity gate, subscribe-before-probe)
are preserved in #21475 for a re-land only after the escalation gate
gets a retention guarantee that covers the disconnected interval.

## Product invariants

- INV-CAP-1 (pendant-audio-retention): the revert removes the battery
policy whose gate could not prove retention across the disconnected
interval. Reconnect pacing returns to the pre-#21404 steady cadence.

## Failure class

Failure-Class: none

## Test plan

- [x] `git revert -m 1 b7e38cf` — clean (it reverses the original
merge wholesale, including its tests and changelog fragment)
- [x] No remaining references to BleReconnectBackoff /
RetentionProbeLifecycle in main or test sources
- [ ] Full CI green on this exact head, then merge to clear the train


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/BasedHardware/omi/pull/21484?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="View guided diff"
src="https://www.cubic.dev/buttons/review-in-cubic-light.svg"></picture></a>
<a
href="https://www.cubic.dev/action/auto-fix/pr/BasedHardware/omi/21484?returnTo=https%3A%2F%2Fgithub.com%2FBasedHardware%2Fomi%2Fpull%2F21484&source=description"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/turn-on-auto-fix-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/turn-on-auto-fix-light.svg"><img
alt="Turn on auto-fix"
src="https://www.cubic.dev/buttons/turn-on-auto-fix-light.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
Git-on-my-level added a commit to Git-on-my-level/omi that referenced this pull request Oct 14, 2026
…ange retry loops (BasedHardware#21404)"

This reverts commit b7e38cf, reversing
changes made to 39bc7b8.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:full Maintainer-granted: run full CI on this fork PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant