Repository navigation
BLE (Android): reliable 5/MG reconnect, attach without a scan, live battery + manual controls - #173
Merged
Merged
Conversation
Reconnect after an involuntary drop used connectToDevice(autoConnect=true), the passive mode that waits for a fresh advertisement or connection-complete event. A 5/MG the OS still holds bonded and ACL-connected never re-emits that event, so the reconnect hangs, while app-start connects instantly through the autoConnect=false fast path. Use the fast direct path for the first two reconnect attempts, then fall back to autoConnect=true from the third attempt for a strap that is genuinely out of range (keeps the ryanbr#61 return-to-range behaviour). Battery on 5/MG comes only from a 0x2A19 read (refreshBattery), which was invoked only from the Live screen, and the keep-alive battery poll was WHOOP4-only, so the ring refreshed only on a manual Body > Health > Sync. Poll 0x2A19 in the keep-alive for 5/MG too (every ~60s, advancing keepAliveTick for both families) and read it once after the connect handshake so it populates on connect.
Add Reconnect and Disconnect actions to the WHOOP device's overflow menu (the same menu as Remove), so there is a one-tap manual recovery when the automatic reconnect is stuck. Reconnect calls AppViewModel.connect(), the same active, direct (autoConnect=false) path onboarding uses, which is why re-running setup always reconnects while a passive auto-reconnect can hang. WHOOP only; shown first in the menu; Disconnect replaces it while connected.
…dDevices) The BLE client was scan-only: it could reach a WHOOP 5/MG only by scanning for its advertisement. Once another app (the official WHOOP app) holds the band it stops advertising, so a manual connect, the add-device search and the automatic reconnect all found nothing and looped on "Scanning for WHOOP...". Android multiplexes one ACL across GATT clients, so the band is still returned by getConnectedDevices even when it is not advertising, and a client can attach to it directly. connect() and the add-device search now try getConnectedDevices first, then a bonded-but-not-connected direct connect, then the scan. Both attach helpers match only a 5/MG strap by name (a WHOOP 4 falls through to the scan) and pin selectedModel to 5/MG on a hit, so a fresh install that still defaults to WHOOP 4.0 attaches to a connected 5/MG instead of scanning for the wrong family; the family is resolved from the discovered GATT services. Also polished the reconnect and battery comments to be implementation-neutral.
rntcruz23
pushed a commit
to rntcruz23/noop
that referenced
this pull request
Jul 12, 2026
Conflict resolutions: - android/app/build.gradle.kts + project.yml: version 8.7.0 (upstream's, the feature set this merge brings in) with the fork's HIGHER build counters (versionCode 278, iOS build 189) so installed fork builds keep updating. - WhoopBleClient.kt: kept the fork's status-147 establishTimeout reset AND upstream's ryanbr#173 regression fix (immediate resetReconnectBackoff on connect, replacing the dwell-gated reset). - SleepScreen.kt: union - fork's Sleep-cycles staircase (SleepCyclesChart + displaySmoothedWeights) alongside upstream's ryanbr#988 stage-timeline family (ClockLabelRow, StageTimeline, StageTimelineRow, StageRowTrack, StageInsight). - RebootProbeVariantTest.kt: upstream's doc-comment wording (content-identical). - docs/WHOOP5_DEEP_DATA.md: union - upstream's ground-truth correlation section followed by the fork's "What your strap has proven" section. - altstore-source.json: kept the fork's manifest wholesale (upstream entries point at upstream's release assets; the fork's release workflow prepends its own next entry). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012pwRSEnFWdsYMC7CLXWNEW
DX23876
pushed a commit
to DX23876/noop
that referenced
this pull request
Jul 22, 2026
…attery + manual controls (ryanbr#173) * fix(ble): reliable 5/MG reconnect + live battery push Reconnect after an involuntary drop used connectToDevice(autoConnect=true), the passive mode that waits for a fresh advertisement or connection-complete event. A 5/MG the OS still holds bonded and ACL-connected never re-emits that event, so the reconnect hangs, while app-start connects instantly through the autoConnect=false fast path. Use the fast direct path for the first two reconnect attempts, then fall back to autoConnect=true from the third attempt for a strap that is genuinely out of range (keeps the ryanbr#61 return-to-range behaviour). Battery on 5/MG comes only from a 0x2A19 read (refreshBattery), which was invoked only from the Live screen, and the keep-alive battery poll was WHOOP4-only, so the ring refreshed only on a manual Body > Health > Sync. Poll 0x2A19 in the keep-alive for 5/MG too (every ~60s, advancing keepAliveTick for both families) and read it once after the connect handshake so it populates on connect. * feat(ui): manual Reconnect/Disconnect in the device menu Add Reconnect and Disconnect actions to the WHOOP device's overflow menu (the same menu as Remove), so there is a one-tap manual recovery when the automatic reconnect is stuck. Reconnect calls AppViewModel.connect(), the same active, direct (autoConnect=false) path onboarding uses, which is why re-running setup always reconnects while a passive auto-reconnect can hang. WHOOP only; shown first in the menu; Disconnect replaces it while connected. * fix(ble): attach to an OS-connected WHOOP without a scan (getConnectedDevices) The BLE client was scan-only: it could reach a WHOOP 5/MG only by scanning for its advertisement. Once another app (the official WHOOP app) holds the band it stops advertising, so a manual connect, the add-device search and the automatic reconnect all found nothing and looped on "Scanning for WHOOP...". Android multiplexes one ACL across GATT clients, so the band is still returned by getConnectedDevices even when it is not advertising, and a client can attach to it directly. connect() and the add-device search now try getConnectedDevices first, then a bonded-but-not-connected direct connect, then the scan. Both attach helpers match only a 5/MG strap by name (a WHOOP 4 falls through to the scan) and pin selectedModel to 5/MG on a hit, so a fresh install that still defaults to WHOOP 4.0 attaches to a connected 5/MG instead of scanning for the wrong family; the family is resolved from the discovered GATT services. Also polished the reconnect and battery comments to be implementation-neutral.
ryanbr
added a commit
that referenced
this pull request
Jul 27, 2026
…ttery policies every second (#886) From a user report of NOOP at 51.75% of battery over 6h31m, confirmed by a second user. Two safe changes, plus the release-hygiene fix that was blocking every Android PR from going green. 1. LOG HOW LONG A LINK WAS HELD. Reaching STATE_CONNECTED zeroes failedReconnectAttempts, and that counter is the ONLY input to scanModeForReconnectAttempts. So a strap that connects and drops repeatedly — edge of range, phone in another room — never accumulates a streak, and every rescan stays on SCAN_MODE_LOW_LATENCY, which is exactly what the PR #588 backoff exists to escape. The #982 guard covers the never-bonded variant of that loop; a bonded-but-short-lived link is not covered. That is a hypothesis, not a measurement, so this changes no behaviour — it makes the pattern readable in an ordinary strap log: Disconnected (status=19); rescanning in 3s (attempt 1, held 24s) A run of drops that all say `attempt 1` with a short `held` IS the bug, with no instrumentation. Attempts climbing normally rules it out and points elsewhere. Snapshotted and cleared at the top of handleDisconnect — a stale linkUpSinceMs surviving into a drop that never reached STATE_CONNECTED would report a hold time for a link that was never up, the diagnostic inventing the evidence it exists to find. handleDisconnect is the single teardown funnel (its own comment: it "publishes connected=false and closes + nulls gatt"), so the clear cannot leak. 2. STOP RE-ASKING THE BATTERY POLICIES EVERY SECOND. The foreground-service collector ticks at live-HR cadence (~1/s while connected) and ran onBatteryUpdate AND onCriticalBattery on every tick. Both early-exit on their own persisted once-per-crossing gates — but not before each does a SharedPreferences read, an areNotificationsEnabled() binder call and an ensureChannel(). Roughly two binder calls and half a dozen prefs reads every second, to re-answer a question the strap only changes every ~8 minutes. Now gated on (SoC, charging) changing, the same shape as the lastRuntimeEvalPct gate two lines below. Gated on the PAIR, not the percentage: onBatteryUpdate owns the charge-complete and re-arm transitions, which key off `charging`. One behavioural delta, stated rather than papered over: enabling the battery-alerts pref while already past a threshold now waits for the next (pct, charging) change instead of firing within a second — up to one battery-report cycle. 3. LOCALIZE THE 9.2.0 WHAT'S NEW TITLE. The 9.2.0 release left main FAILING i18n-coverage. appchangelog-gen writes the title as a raw Kotlin literal; extraction into a string resource is a separate step that normally happens in the PR flow where the gate catches it, and fork-release.yml commits straight to main. That also means the 9.2.0 title shipped UNTRANSLATED on Android — the only one of 240+ release entries to do so. Resource added under the existing convention and translated in all five locale files. The convention was DERIVED, not guessed: first six slug words plus sha1(unescaped value)[:8], which reproduces 267 of 268 existing keys — the single exception being the key I had first written by eyeballing a few examples and assuming a ~40-character truncation. values-zh closed rather than its allowance raised, because Tools/i18n_extra_locale_baseline.txt says in its own header to ratchet DOWN, never up. This will recur on every release until the extraction runs inside fork-release.yml. RULED OUT while looking, all already defended: the FGS notification dedupes on a connected|backfilling|recovery|battery key (live HR is not in it); the Glance widget push has a cheap non-suspending PushGate; the offload chunk counter republishes every ~10 chunks; connection priority does not escalate for live HR by default; reconnect backoff is capped 3s->60s; IllnessWatch.evaluate is bounded to <=31 days of means; uiString is ~1us per call. NOT DONE, deliberately: making the reconnect streak survive a short-lived connection. That reset exists to stop a flapping co-resident band from freezing the link (#173 regression, where a climbing counter escalated to PASSIVE autoConnect and stalled). Changing it blind, on an unverified hypothesis, on the path that keeps the strap connected at all, trades a battery complaint for a strap that does not come back. The hold-time log is what tells us whether it is worth doing. Register verified rather than assumed: all three Android locales are decisively informal (es 35:1, de 118:6, pt 241:0 on unambiguous markers), matching the strings added here. No CI job compiles Kotlin, so every symbol was checked against its declaration by hand.
simoncad7
pushed a commit
to simoncad7/noop
that referenced
this pull request
Aug 17, 2026
…attery + manual controls (ryanbr#173) * fix(ble): reliable 5/MG reconnect + live battery push Reconnect after an involuntary drop used connectToDevice(autoConnect=true), the passive mode that waits for a fresh advertisement or connection-complete event. A 5/MG the OS still holds bonded and ACL-connected never re-emits that event, so the reconnect hangs, while app-start connects instantly through the autoConnect=false fast path. Use the fast direct path for the first two reconnect attempts, then fall back to autoConnect=true from the third attempt for a strap that is genuinely out of range (keeps the ryanbr#61 return-to-range behaviour). Battery on 5/MG comes only from a 0x2A19 read (refreshBattery), which was invoked only from the Live screen, and the keep-alive battery poll was WHOOP4-only, so the ring refreshed only on a manual Body > Health > Sync. Poll 0x2A19 in the keep-alive for 5/MG too (every ~60s, advancing keepAliveTick for both families) and read it once after the connect handshake so it populates on connect. * feat(ui): manual Reconnect/Disconnect in the device menu Add Reconnect and Disconnect actions to the WHOOP device's overflow menu (the same menu as Remove), so there is a one-tap manual recovery when the automatic reconnect is stuck. Reconnect calls AppViewModel.connect(), the same active, direct (autoConnect=false) path onboarding uses, which is why re-running setup always reconnects while a passive auto-reconnect can hang. WHOOP only; shown first in the menu; Disconnect replaces it while connected. * fix(ble): attach to an OS-connected WHOOP without a scan (getConnectedDevices) The BLE client was scan-only: it could reach a WHOOP 5/MG only by scanning for its advertisement. Once another app (the official WHOOP app) holds the band it stops advertising, so a manual connect, the add-device search and the automatic reconnect all found nothing and looped on "Scanning for WHOOP...". Android multiplexes one ACL across GATT clients, so the band is still returned by getConnectedDevices even when it is not advertising, and a client can attach to it directly. connect() and the add-device search now try getConnectedDevices first, then a bonded-but-not-connected direct connect, then the scan. Both attach helpers match only a 5/MG strap by name (a WHOOP 4 falls through to the scan) and pin selectedModel to 5/MG on a hit, so a fresh install that still defaults to WHOOP 4.0 attaches to a connected 5/MG instead of scanning for the wrong family; the family is resolved from the discovered GATT services. Also polished the reconnect and battery comments to be implementation-neutral.
simoncad7
pushed a commit
to simoncad7/noop
that referenced
this pull request
Aug 17, 2026
…ttery policies every second (ryanbr#886) From a user report of NOOP at 51.75% of battery over 6h31m, confirmed by a second user. Two safe changes, plus the release-hygiene fix that was blocking every Android PR from going green. 1. LOG HOW LONG A LINK WAS HELD. Reaching STATE_CONNECTED zeroes failedReconnectAttempts, and that counter is the ONLY input to scanModeForReconnectAttempts. So a strap that connects and drops repeatedly — edge of range, phone in another room — never accumulates a streak, and every rescan stays on SCAN_MODE_LOW_LATENCY, which is exactly what the PR ryanbr#588 backoff exists to escape. The ryanbr#982 guard covers the never-bonded variant of that loop; a bonded-but-short-lived link is not covered. That is a hypothesis, not a measurement, so this changes no behaviour — it makes the pattern readable in an ordinary strap log: Disconnected (status=19); rescanning in 3s (attempt 1, held 24s) A run of drops that all say `attempt 1` with a short `held` IS the bug, with no instrumentation. Attempts climbing normally rules it out and points elsewhere. Snapshotted and cleared at the top of handleDisconnect — a stale linkUpSinceMs surviving into a drop that never reached STATE_CONNECTED would report a hold time for a link that was never up, the diagnostic inventing the evidence it exists to find. handleDisconnect is the single teardown funnel (its own comment: it "publishes connected=false and closes + nulls gatt"), so the clear cannot leak. 2. STOP RE-ASKING THE BATTERY POLICIES EVERY SECOND. The foreground-service collector ticks at live-HR cadence (~1/s while connected) and ran onBatteryUpdate AND onCriticalBattery on every tick. Both early-exit on their own persisted once-per-crossing gates — but not before each does a SharedPreferences read, an areNotificationsEnabled() binder call and an ensureChannel(). Roughly two binder calls and half a dozen prefs reads every second, to re-answer a question the strap only changes every ~8 minutes. Now gated on (SoC, charging) changing, the same shape as the lastRuntimeEvalPct gate two lines below. Gated on the PAIR, not the percentage: onBatteryUpdate owns the charge-complete and re-arm transitions, which key off `charging`. One behavioural delta, stated rather than papered over: enabling the battery-alerts pref while already past a threshold now waits for the next (pct, charging) change instead of firing within a second — up to one battery-report cycle. 3. LOCALIZE THE 9.2.0 WHAT'S NEW TITLE. The 9.2.0 release left main FAILING i18n-coverage. appchangelog-gen writes the title as a raw Kotlin literal; extraction into a string resource is a separate step that normally happens in the PR flow where the gate catches it, and fork-release.yml commits straight to main. That also means the 9.2.0 title shipped UNTRANSLATED on Android — the only one of 240+ release entries to do so. Resource added under the existing convention and translated in all five locale files. The convention was DERIVED, not guessed: first six slug words plus sha1(unescaped value)[:8], which reproduces 267 of 268 existing keys — the single exception being the key I had first written by eyeballing a few examples and assuming a ~40-character truncation. values-zh closed rather than its allowance raised, because Tools/i18n_extra_locale_baseline.txt says in its own header to ratchet DOWN, never up. This will recur on every release until the extraction runs inside fork-release.yml. RULED OUT while looking, all already defended: the FGS notification dedupes on a connected|backfilling|recovery|battery key (live HR is not in it); the Glance widget push has a cheap non-suspending PushGate; the offload chunk counter republishes every ~10 chunks; connection priority does not escalate for live HR by default; reconnect backoff is capped 3s->60s; IllnessWatch.evaluate is bounded to <=31 days of means; uiString is ~1us per call. NOT DONE, deliberately: making the reconnect streak survive a short-lived connection. That reset exists to stop a flapping co-resident band from freezing the link (ryanbr#173 regression, where a climbing counter escalated to PASSIVE autoConnect and stalled). Changing it blind, on an unverified hypothesis, on the path that keeps the strap connected at all, trades a battery complaint for a strap that does not come back. The hold-time log is what tells us whether it is worth doing. Register verified rather than assumed: all three Android locales are decisively informal (es 35:1, de 118:6, pt 241:0 on unambiguous markers), matching the strings added here. No CI job compiles Kotlin, so every symbol was checked against its declaration by hand.
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.
Summary
Makes WHOOP 5.0/MG reconnect, manual connect and the add-device search actually reach a band, adds a live battery refresh for 5/MG, and gives a one-tap manual recovery in the device menu. Three focused commits; WHOOP 4 behaviour is unchanged.
Problem
1. Scan-only could not reach a band another app holds. The BLE client reached a 5/MG only by scanning for its advertisement. Once the official WHOOP app holds the band it stops advertising, so a manual connect, the add-device search and the automatic reconnect all found nothing and looped on
Scanning for WHOOP...forever.2. Reconnect after a drop used the passive path and hung. Reconnect called
connectToDevice(autoConnect = true), the mode that waits for a fresh advertisement or connection-complete event. A 5/MG the OS still holds bonded and ACL-connected never re-emits that event, so the reconnect stalled, while an app-start connect returns instantly through theautoConnect = falsefast path.3. 5/MG battery only refreshed on a manual sync. Battery on 5/MG comes only from a
0x2A19read (refreshBattery), which was invoked only from the Live screen, and the keep-alive battery poll was WHOOP4-only. The battery ring refreshed only on a manual Body > Health > Sync.Fix
getConnectedDeviceseven when it is not advertising, and a client can attach to it directly.connect()and the add-device search now trygetConnectedDevicesfirst, then a bonded-but-not-connected direct connect, then the scan. Both attach helpers match only a 5/MG strap by name (a WHOOP 4 falls through to the scan) and pinselectedModelto 5/MG on a hit, so a fresh install still defaulting to WHOOP 4.0 attaches to a connected 5/MG instead of scanning for the wrong family; the family is resolved from the discovered GATT services.autoConnect = falsepath for the first two reconnect attempts, then fall back toautoConnect = truefrom the third attempt for a strap that is genuinely out of range (keeps the return-to-range behaviour).0x2A19in the keep-alive for 5/MG too (~60s, advancing the keep-alive tick for both families) and read it once after the connect handshake so it populates on connect.Notes
Commits
fix(ble): reliable 5/MG reconnect + live battery pushfeat(ui): manual Reconnect/Disconnect in the device menufix(ble): attach to an OS-connected WHOOP without a scan (getConnectedDevices)