Skip to content

feat(ble): battery-adaptive BLE — connection-priority + offload-cadence, dormant default-off (#477) - #478

Merged
ryanbr merged 11 commits into
mainfrom
ble-connection-priority
Jul 15, 2026
Merged

ryanbr merged 11 commits into
mainfrom
ble-connection-priority

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Implements the Android battery lever coordinated in #477, shipped dormant (default-off) so it merges completely inert and the behaviour change waits for on-strap validation.

What

Request CONNECTION_PRIORITY_HIGH during an offload burst / live-HR session, and — behind an opt-in idle throttle — CONNECTION_PRIORITY_LOW_POWER when the connection is idle. Today the WHOOP link runs at the default BALANCED for its whole lifetime (requestConnectionPriority is never called), even though it's idle most of the day.

Safe / risky split (per #477)

  • SAFE — HIGH during offload/live-HR: HIGH is a shorter interval than BALANCED, so it cannot cause a supervision-timeout drop (it makes the link more robust) and it shortens the radio-on window → faster sync, net battery win.
  • RISKY — LOW_POWER idle (idleThrottleEnabled, off): a longer interval — the real all-day saving, but a too-long interval can drop the link, so it's opt-in and must be validated on hardware.

Dormant by default

connectionPriorityEnabled defaults false, so refreshConnectionPriority() early-returns and issues zero new BLE ops — the link stays at the stack default exactly as before. Merging changes nothing. setConnectionPriorityManagement(enabled, idleThrottle) is the seam a follow-up wires to a persisted Settings toggle, after the validation in #477.

Implementation

  • Pure connectionPriorityFor(offloadActive, liveHrActive, idleThrottleEnabled) in the companion — the scanModeForReconnectAttempts (Repository.mergeActivityFileSteps: graceful last-wins on duplicate day (parity + no trap) #588) idiom, unit-testable with no BLE stack.
  • GattOps.requestConnectionPriorityCompat added to the existing injectable seam (RealGattOps delegates to gatt.requestConnectionPriority).
  • Wired at the real transitions: offload begin → HIGH, exitBackfilling → idle, reconcileRealtime → HIGH/idle. Reads the authoritative internal flags (backfilling/realtimeArmed), not the published LiveState mirror which updates a beat later.

Parity

Android-only by necessity. CoreBluetooth exposes no app-side connection-priority equivalent (the peripheral proposes the GAP connection parameters, iOS negotiates), so there's no Swift twin — a deliberate platform divergence documented in the connectionPriorityFor doc, not a parity gap. (#477)

Verification

  • :app:compileFullDebugKotlin — compiles.
  • ConnectionPriorityTest (4 cases: active→HIGH incl. over the throttle, idle+off→BALANCED, idle+on→LOW_POWER) — green.
  • On-strap validation is pending and gates enabling, not this dormant merge — per [ble] Battery: GATT connection-priority management (HIGH during offload/live, opt-in LOW_POWER idle) — Android #477: safe half first (connect → offload → confirm sync completes, zero new involuntary drops), then a multi-day idle-throttle trial (battery delta + drop count). Compile/unit tests prove the logic, nothing about BLE behaviour.

Do not flip the toggle on / promote to default-on until that hardware trial is done.

…477)

Adds the battery lever coordinated in #477: request CONNECTION_PRIORITY_HIGH
during an offload burst / live-HR session (a shorter interval → faster sync,
can't cause a supervision-timeout drop) and, behind an opt-in idle throttle,
LOW_POWER when idle. Ships DORMANT — the master gate defaults off, so
refreshConnectionPriority() early-returns and issues zero new BLE ops, leaving
the link at the stack default (BALANCED) exactly as today.

- Pure connectionPriorityFor(offloadActive, liveHrActive, idleThrottleEnabled)
  in the companion (the scanModeForReconnectAttempts idiom), unit-tested.
- GattOps.requestConnectionPriorityCompat seam (RealGattOps delegate).
- Wired at the real transitions: offload begin → HIGH, exitBackfilling → idle,
  reconcileRealtime → HIGH/idle. Reads the authoritative internal flags.
- Android-only by necessity: CoreBluetooth has no app-side connection-priority
  equivalent, so there is no Swift twin — documented divergence, not a gap.

Compiles (:app:compileFullDebugKotlin); ConnectionPriorityTest green. Behaviour
change is gated OFF pending on-strap validation per #477.
… re-review)

refreshConnectionPriority routed the requestConnectionPriority call through
safeGatt, whose policy is 'any throw ⇒ teardownAfterGattFailure()'. That's
right for load-bearing writes/subscriptions but wrong for a battery HINT: a
transient throw would sacrifice the whole connection for an optimization.
Swallow locally instead — a dead binder is handled by the next real op.
@ryanbr

ryanbr commented Jul 15, 2026

Copy link
Copy Markdown
Owner Author

Re-review found and fixed one real bug (a50de0a):

The battery hint could tear down the connection. refreshConnectionPriority routed requestConnectionPriority through safeGatt, whose policy is any throw ⇒ teardownAfterGattFailure(). That's correct for load-bearing writes/subscriptions, but wrong for a battery hint — a transient throw would sacrifice the whole link for an optimization. Now swallowed locally (a dead binder is handled by the next real op; skipping a priority request costs nothing). Recompiled, ConnectionPriorityTest still green.

Also reviewed and accepted as-is: the non-@Volatile backfilling/realtimeArmed reads in refreshConnectionPriority can be momentarily stale if a hook runs off the main looper, but for a battery hint the worst case is one cycle of wrong priority that self-heals on the next transition — and it's no worse than the existing backfilling access pattern. Not worth adding looper-marshalling complexity to a dormant path.

ryanbr added 2 commits July 15, 2026 18:08
Instead of a static on/off, the RISKY idle throttle now engages only while the
phone is DISCHARGING and at/below a user-selectable battery %% (picker: 10/15/
20/25/30; 0 = never). Confines the drop-risk to when the user actually wants
power saving. Pure idleThrottleActive(batteryPct, charging, thresholdPct) +
tests; battery read from the sticky ACTION_BATTERY_CHANGED intent (no persistent
receiver), fails SAFE (unknown → 100%%, never throttles). Still dormant behind
the default-off master gate; the safe HIGH-escalation half is unaffected.
Stretch the 15-min periodic offload to 45 min while discharging at/below a
user-selectable battery %% (picker 10/15/20/25/30; 0 = off). The offload tick is
a pure sync timer — the live-stream keep-alive is a separate mechanism — so this
can't affect link health; worst case is data arriving in larger batches (the
strap banks to flash meanwhile, no loss). Pure offloadIntervalMsFor(...) + tests.

Ships DORMANT: threshold defaults 0, and nextBackfillDelayMs early-returns the
normal cadence with ZERO battery reads until opted in. Reuses the #478 battery
snapshot; complements the connection-priority lever, lower-risk (no drop risk).
@ryanbr ryanbr changed the title feat(ble): GATT connection-priority management, dormant default-off (#477) feat(ble): battery-adaptive BLE — connection-priority + offload-cadence, dormant default-off (#477) Jul 15, 2026
@ryanbr

ryanbr commented Jul 15, 2026

Copy link
Copy Markdown
Owner Author

Added the second, lower-risk battery lever from #477: battery-adaptive periodic-offload cadence.

Stretches the 15-min periodic offload to 45 min while discharging at/below a user-selectable % (10/15/20/25/30; 0 = off). Verified the safety-critical fact first: the offload tick is a pure sync timer — the live-stream keep-alive (keepAliveRunnable) is a separate mechanism — so stretching it cannot affect link health. Worst case is data arriving in larger batches; the strap banks to flash meanwhile, so nothing is lost. That makes this lever materially lower-risk than the connection-priority one (no drop risk at all).

Both levers compile (:app:compileFullDebugKotlin) and ConnectionPriorityTest (8 cases) is green. Still all dormant behind their default-off gates — the offload lever needs far lighter validation than connection-priority since it can't drop the link.

ryanbr added 3 commits July 15, 2026 18:14
refreshConnectionPriority read the battery on every transition even when the
idle throttle was off (idleThrottleBatteryPct == 0), where the result is always
false. Short-circuit so the SAFE HIGH-escalation half issues no battery read —
mirrors the nextBackfillDelayMs dormancy fix.
…rs (#477)

Both levers (idle LOW_POWER throttle + offload-cadence stretch) now also engage
when the OS Battery Saver is on — the user's explicit 'save power' signal, with
its own hysteresis + charging-awareness. Battery Saver is an OR-WITHIN an
already-armed lever (threshold > 0): it can trigger a lever you opted into at any
battery level, but it can NOT force the risky idle throttle against a deliberate
'off' (threshold 0), respecting the drop-risk asymmetry. powerSave threads through
the pure gates (idleThrottleActive / offloadIntervalMsFor) + tests; read only when
a threshold is armed, so still dormant by default.
…er (#477)

A separate default-off toggle: while the OS Battery Saver is on, release the
held-open BACKGROUND continuous-capture realtime stream (one of the larger live
drains). A visible Live screen is unaffected (screenWantsRealtime is separate),
and it re-arms automatically when Battery Saver turns off.

Rides the existing #927 window-gating machinery: the gate lives in
continuousCaptureWantsNow(), which the 30s keep-alive tick already re-derives and
arms/disarms on the edge — so no new receiver or re-arm path, and no stuck-paused
risk. Dormant by default (pauseCaptureOnPowerSave=false short-circuits the
PowerManager read). setPauseCaptureOnPowerSave reconciles immediately.
@ryanbr

ryanbr commented Jul 15, 2026

Copy link
Copy Markdown
Owner Author

Re-review notes (scope + safety):

Safety — capture-pause can't cause reconnect thrash (checked). Disarming the continuous-capture stream could in theory trip the keep-alive's 120s data-watchdog and bounce the link. It doesn't: onInbound resets lastDataAtMs on any inbound frame, and the keep-alive polls battery every ~60s regardless of whether realtime is armed (outside the wantsRealtime branch), so the link stays under the 120s fuse. Same protection that makes the #927 overnight-close disarm safe.

iOS parity scope (deliberate, stated). Of the three levers:

  • Connection priority — Android-only by necessity (no CoreBluetooth equivalent).
  • Offload-cadence stretch + continuous-capture pause — iOS-implementable (iOS has the same offload timer + keepRealtimeForData path), currently Android-only. Rationale for Android-first: iOS's Low Power Mode already auto-throttles background BLE (surfaced in the app's own iOS diagnostics), so Android needs these explicit levers to compensate for the OS-level throttling it doesn't get for free. iOS twins for these two are a reasonable follow-up once the Android approach is validated on hardware — flagged so it isn't a silent gap.

Wires the two BENIGN battery levers to a user-facing Settings section, so the
dormant mechanism is now reachable:
- 'Power saving mode' toggle + a 10/15/20/25/30 %% picker → the offload-cadence
  stretch (setLowBatteryOffloadThrottle). Default off / 20%.
- 'Pause HRV capture in Battery Saver' tick box → the continuous-capture pause
  (setPauseCaptureOnPowerSave). Default off.

Persisted in NoopPrefs; AppViewModel.applyPowerSaving() pushes them at launch and
on every change. The riskier connection-priority idle throttle is deliberately
NOT surfaced — it stays dormant pending on-strap validation (#478). Compiles
(:app:compileFullDebugKotlin).
@ryanbr

ryanbr commented Jul 15, 2026

Copy link
Copy Markdown
Owner Author

Added the Settings UI — the feature is now user-reachable (was headless mechanism-only before).

Settings → Power saving (a new everyday section, above Advanced):

  • Power saving mode toggle + a 10/15/20/25/30% picker → the offload-cadence stretch (setLowBatteryOffloadThrottle). Default off / 20%.
  • Pause HRV capture in Battery Saver tick box → the continuous-capture pause (setPauseCaptureOnPowerSave). Default off.

Persisted in NoopPrefs; AppViewModel.applyPowerSaving() pushes both at launch and on every change (same pattern as the Continuous-HRV setting).

Deliberately NOT in the UI: the connection-priority idle throttle — it's the only lever with drop-risk, so it stays dormant/headless pending the on-strap trial, rather than being a user-flippable toggle. Only the two benign levers (offload cadence + HRV pause) are exposed.

Compiles (:app:compileFullDebugKotlin). So a user can now flip these on and the benign battery savings take effect; the risky lever still requires deliberate enabling for validation.

ryanbr added 3 commits July 15, 2026 18:45
Swap the 10/15/20/25/30 pill picker for a stepped Slider (10-30%, snapping to 5%
increments) with a live '20%' read-out. Persists on onValueChangeFinished (not
every drag frame). Default stays 20%.
Make the HRV-capture pause a sub-option of the Power saving master, not an
independent toggle: it shows only when Power saving is on, defaults ON (pref
default flipped to true), and stays user-disableable. Effective HRV pause is now
master && pref, so turning Power saving off disables it too. Resolves the
master/sub-toggle inconsistency from re-review.
Wire the 7 new Settings strings to R.string.* + German translations, so the #452
i18n gate passes (it correctly failed the new hardcoded literals). HRV=HFV per
the existing #451 vocabulary; %1$d%% readout keeps the format specifier.
@ryanbr
ryanbr merged commit 53887f0 into main Jul 15, 2026
1 check passed
@ryanbr
ryanbr deleted the ble-connection-priority branch July 15, 2026 06:57
ryanbr added a commit that referenced this pull request Jul 15, 2026
Rolling testing build off main incl. #478 (battery-adaptive BLE: Power saving
Settings — offload-cadence % slider + HRV pause; connection-priority lever stays
dormant). Build numbers 196/285 -> 197/286.
ryanbr added a commit that referenced this pull request Jul 17, 2026
… priority (#533) (#536)

* ble: escalate connection priority for the offload burst (#533)

#477 shipped GATT connection-priority management fully implemented but
DORMANT: setConnectionPriorityManagement was never called from anywhere, so
connectionPriorityEnabled stayed false, refreshConnectionPriority early-returned
at every call site, and EVERY historical offload has run at the stack default.
The escalation hook is already in the right place (enterBackfilling ->
'escalate to HIGH for the offload burst (faster sync)') — nothing switched it on.

Switch on the SAFE half. The offload burst is bounded (HISTORY_COMPLETE / idle
timeout) and bandwidth-hungry, so a shorter interval moves the same bytes in
LESS radio-on wall-clock — the sync gets faster without a proportional energy
cost. The RISKY idle->LOW_POWER half stays at 0 (still dormant, #478).

Live-HR deliberately does NOT escalate (new escalateForLiveHr knob, default
off). realtimeArmed is true for the whole OVERNIGHT continuous-HRV window
(22:00-07:00 by default via continuousCaptureWantsNow), not just while a Live
screen is open — escalating it would hold an ~11.25 ms interval for HOURS to
carry a 1 Hz HR/RR stream that BALANCED already serves: sustained drain on both
strap and phone for no throughput gain. The knob keeps the resolver branch live
rather than dead, since the opt-in R22 deep-buffer capture is the one high-rate
live case that could legitimately want HIGH.

NOT VALIDATED ON HARDWARE — I have no strap. BLE behaviour can't be CI- or
Linux-tested, so the speedup and the battery delta both need an on-strap
measurement before this should be trusted. The existing strap log already
timestamps 'Backfill: session started'/'session ended' next to 'persisted N
rows', so records/sec is measurable from a before/after log with no new
instrumentation.

compileFullDebugKotlin + ConnectionPriorityTest (9) green.

* settings: put the faster-offload escalation behind an experimental toggle

Follow-up on the same branch: rather than switching the #477 escalation on for
everyone, gate it behind Settings > Strap > 'Faster history sync (experimental)',
default OFF.

BLE behaviour can't be CI- or Linux-tested, so neither the speedup nor the
battery cost can be proven here — an opt-in toggle lets it ship and collect real
field reports instead of blocking on one person's strap, and lets anyone who
finds it unhelpful (or costly) turn it straight back off. Matches how the other
unvalidated levers are handled.

- NoopPrefs.KEY_FAST_HISTORY_SYNC (default false) + getter/setter
- AppViewModel.setFastHistorySync persists + re-applies immediately, so the next
  offload burst picks it up without a reconnect; applyPowerSaving now reads the
  pref instead of hardcoding enabled=true. NOT gated on the Power-saving master
  (this spends radio for speed; it is not a power-saving lever)
- Settings toggle lives in the Strap card next to 'Keep connected in the
  background', not in Power saving (a card about saving power is the wrong home
  for a switch that spends it)
- strings added to values + de/es/fr (the coverage half of the gate)

i18n audit OK (no hardcoded literals, all focus locales); compileFullDebugKotlin
+ ConnectionPriorityTest (9) green.

* ble: release the link back to default when the sync experiment is turned off

Re-review catch on my own change. refreshConnectionPriority() early-returns on
!connectionPriorityEnabled, so setConnectionPriorityManagement(enabled = false)
issued NO BLE op at all: turning the toggle off only stopped FUTURE escalations
and left a link already pinned at CONNECTION_PRIORITY_HIGH there until the next
reconnect.

That breaks the toggle's own promise. The Settings copy says to turn it back off
if it costs more battery than it's worth — but a user doing exactly that would
keep paying for it, potentially for hours, since 'Keep connected in the
background' (the switch directly above it) holds the link open.

On a real on->off edge, request BALANCED once to hand the link back to the stack
default. Every other transition still issues nothing — notably the default launch
path, which re-applies enabled=false while already off and stays byte-for-byte
today's zero-BLE-op behaviour.

The edge rule is a pure companion predicate (releasesConnectionPriority),
matching this file's existing idiom (connectionPriorityFor / idleThrottleActive /
offloadIntervalMsFor), since WhoopBleClient needs a Context and can't be built in
the pure-JVM suite. Release swallows throws like the refresh path — a priority
hint must never tear the link down.

The latent flaw is #477's, but nothing could reach it while the feature was
dormant; exposing the toggle is what makes the disable path reachable.

ConnectionPriorityTest 11/11 (adds both edge cases); compileFullDebugKotlin +
i18n audit green.
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…ce, dormant default-off (ryanbr#477) (ryanbr#478)

* feat(ble): GATT connection-priority management, dormant default-off (ryanbr#477)

Adds the battery lever coordinated in ryanbr#477: request CONNECTION_PRIORITY_HIGH
during an offload burst / live-HR session (a shorter interval → faster sync,
can't cause a supervision-timeout drop) and, behind an opt-in idle throttle,
LOW_POWER when idle. Ships DORMANT — the master gate defaults off, so
refreshConnectionPriority() early-returns and issues zero new BLE ops, leaving
the link at the stack default (BALANCED) exactly as today.

- Pure connectionPriorityFor(offloadActive, liveHrActive, idleThrottleEnabled)
  in the companion (the scanModeForReconnectAttempts idiom), unit-tested.
- GattOps.requestConnectionPriorityCompat seam (RealGattOps delegate).
- Wired at the real transitions: offload begin → HIGH, exitBackfilling → idle,
  reconcileRealtime → HIGH/idle. Reads the authoritative internal flags.
- Android-only by necessity: CoreBluetooth has no app-side connection-priority
  equivalent, so there is no Swift twin — documented divergence, not a gap.

Compiles (:app:compileFullDebugKotlin); ConnectionPriorityTest green. Behaviour
change is gated OFF pending on-strap validation per ryanbr#477.

* fix(ble): connection-priority hint must not trigger link teardown (ryanbr#478 re-review)

refreshConnectionPriority routed the requestConnectionPriority call through
safeGatt, whose policy is 'any throw ⇒ teardownAfterGattFailure()'. That's
right for load-bearing writes/subscriptions but wrong for a battery HINT: a
transient throw would sacrifice the whole connection for an optimization.
Swallow locally instead — a dead binder is handled by the next real op.

* feat(ble): make the idle LOW_POWER throttle battery-adaptive (ryanbr#477)

Instead of a static on/off, the RISKY idle throttle now engages only while the
phone is DISCHARGING and at/below a user-selectable battery %% (picker: 10/15/
20/25/30; 0 = never). Confines the drop-risk to when the user actually wants
power saving. Pure idleThrottleActive(batteryPct, charging, thresholdPct) +
tests; battery read from the sticky ACTION_BATTERY_CHANGED intent (no persistent
receiver), fails SAFE (unknown → 100%%, never throttles). Still dormant behind
the default-off master gate; the safe HIGH-escalation half is unaffected.

* feat(ble): battery-adaptive periodic-offload cadence (ryanbr#477)

Stretch the 15-min periodic offload to 45 min while discharging at/below a
user-selectable battery %% (picker 10/15/20/25/30; 0 = off). The offload tick is
a pure sync timer — the live-stream keep-alive is a separate mechanism — so this
can't affect link health; worst case is data arriving in larger batches (the
strap banks to flash meanwhile, no loss). Pure offloadIntervalMsFor(...) + tests.

Ships DORMANT: threshold defaults 0, and nextBackfillDelayMs early-returns the
normal cadence with ZERO battery reads until opted in. Reuses the ryanbr#478 battery
snapshot; complements the connection-priority lever, lower-risk (no drop risk).

* perf(ble): skip the battery read in safe-half-only mode (ryanbr#478 re-review)

refreshConnectionPriority read the battery on every transition even when the
idle throttle was off (idleThrottleBatteryPct == 0), where the result is always
false. Short-circuit so the SAFE HIGH-escalation half issues no battery read —
mirrors the nextBackfillDelayMs dormancy fix.

* feat(ble): respect Android Battery Saver in the battery-adaptive levers (ryanbr#477)

Both levers (idle LOW_POWER throttle + offload-cadence stretch) now also engage
when the OS Battery Saver is on — the user's explicit 'save power' signal, with
its own hysteresis + charging-awareness. Battery Saver is an OR-WITHIN an
already-armed lever (threshold > 0): it can trigger a lever you opted into at any
battery level, but it can NOT force the risky idle throttle against a deliberate
'off' (threshold 0), respecting the drop-risk asymmetry. powerSave threads through
the pure gates (idleThrottleActive / offloadIntervalMsFor) + tests; read only when
a threshold is armed, so still dormant by default.

* feat(ble): optional pause of continuous-HRV capture under Battery Saver (ryanbr#477)

A separate default-off toggle: while the OS Battery Saver is on, release the
held-open BACKGROUND continuous-capture realtime stream (one of the larger live
drains). A visible Live screen is unaffected (screenWantsRealtime is separate),
and it re-arms automatically when Battery Saver turns off.

Rides the existing ryanbr#927 window-gating machinery: the gate lives in
continuousCaptureWantsNow(), which the 30s keep-alive tick already re-derives and
arms/disarms on the edge — so no new receiver or re-arm path, and no stuck-paused
risk. Dormant by default (pauseCaptureOnPowerSave=false short-circuits the
PowerManager read). setPauseCaptureOnPowerSave reconciles immediately.

* feat(ui): Settings — Power saving section (battery % + HRV pause) (ryanbr#477)

Wires the two BENIGN battery levers to a user-facing Settings section, so the
dormant mechanism is now reachable:
- 'Power saving mode' toggle + a 10/15/20/25/30 %% picker → the offload-cadence
  stretch (setLowBatteryOffloadThrottle). Default off / 20%.
- 'Pause HRV capture in Battery Saver' tick box → the continuous-capture pause
  (setPauseCaptureOnPowerSave). Default off.

Persisted in NoopPrefs; AppViewModel.applyPowerSaving() pushes them at launch and
on every change. The riskier connection-priority idle throttle is deliberately
NOT surfaced — it stays dormant pending on-strap validation (ryanbr#478). Compiles
(:app:compileFullDebugKotlin).

* feat(ui): use a slider for the power-saving battery threshold (ryanbr#477)

Swap the 10/15/20/25/30 pill picker for a stepped Slider (10-30%, snapping to 5%
increments) with a live '20%' read-out. Persists on onValueChangeFinished (not
every drag frame). Default stays 20%.

* feat(ui): HRV pause is a power-saving sub-option, on by default (ryanbr#477)

Make the HRV-capture pause a sub-option of the Power saving master, not an
independent toggle: it shows only when Power saving is on, defaults ON (pref
default flipped to true), and stays user-disableable. Effective HRV pause is now
master && pref, so turning Power saving off disables it too. Resolves the
master/sub-toggle inconsistency from re-review.

* l10n(de): localize the Power saving Settings section (ryanbr#477)

Wire the 7 new Settings strings to R.string.* + German translations, so the ryanbr#452
i18n gate passes (it correctly failed the new hardcoded literals). HRV=HFV per
the existing ryanbr#451 vocabulary; %1$d%% readout keeps the format specifier.
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
… priority (ryanbr#533) (ryanbr#536)

* ble: escalate connection priority for the offload burst (ryanbr#533)

ryanbr#477 shipped GATT connection-priority management fully implemented but
DORMANT: setConnectionPriorityManagement was never called from anywhere, so
connectionPriorityEnabled stayed false, refreshConnectionPriority early-returned
at every call site, and EVERY historical offload has run at the stack default.
The escalation hook is already in the right place (enterBackfilling ->
'escalate to HIGH for the offload burst (faster sync)') — nothing switched it on.

Switch on the SAFE half. The offload burst is bounded (HISTORY_COMPLETE / idle
timeout) and bandwidth-hungry, so a shorter interval moves the same bytes in
LESS radio-on wall-clock — the sync gets faster without a proportional energy
cost. The RISKY idle->LOW_POWER half stays at 0 (still dormant, ryanbr#478).

Live-HR deliberately does NOT escalate (new escalateForLiveHr knob, default
off). realtimeArmed is true for the whole OVERNIGHT continuous-HRV window
(22:00-07:00 by default via continuousCaptureWantsNow), not just while a Live
screen is open — escalating it would hold an ~11.25 ms interval for HOURS to
carry a 1 Hz HR/RR stream that BALANCED already serves: sustained drain on both
strap and phone for no throughput gain. The knob keeps the resolver branch live
rather than dead, since the opt-in R22 deep-buffer capture is the one high-rate
live case that could legitimately want HIGH.

NOT VALIDATED ON HARDWARE — I have no strap. BLE behaviour can't be CI- or
Linux-tested, so the speedup and the battery delta both need an on-strap
measurement before this should be trusted. The existing strap log already
timestamps 'Backfill: session started'/'session ended' next to 'persisted N
rows', so records/sec is measurable from a before/after log with no new
instrumentation.

compileFullDebugKotlin + ConnectionPriorityTest (9) green.

* settings: put the faster-offload escalation behind an experimental toggle

Follow-up on the same branch: rather than switching the ryanbr#477 escalation on for
everyone, gate it behind Settings > Strap > 'Faster history sync (experimental)',
default OFF.

BLE behaviour can't be CI- or Linux-tested, so neither the speedup nor the
battery cost can be proven here — an opt-in toggle lets it ship and collect real
field reports instead of blocking on one person's strap, and lets anyone who
finds it unhelpful (or costly) turn it straight back off. Matches how the other
unvalidated levers are handled.

- NoopPrefs.KEY_FAST_HISTORY_SYNC (default false) + getter/setter
- AppViewModel.setFastHistorySync persists + re-applies immediately, so the next
  offload burst picks it up without a reconnect; applyPowerSaving now reads the
  pref instead of hardcoding enabled=true. NOT gated on the Power-saving master
  (this spends radio for speed; it is not a power-saving lever)
- Settings toggle lives in the Strap card next to 'Keep connected in the
  background', not in Power saving (a card about saving power is the wrong home
  for a switch that spends it)
- strings added to values + de/es/fr (the coverage half of the gate)

i18n audit OK (no hardcoded literals, all focus locales); compileFullDebugKotlin
+ ConnectionPriorityTest (9) green.

* ble: release the link back to default when the sync experiment is turned off

Re-review catch on my own change. refreshConnectionPriority() early-returns on
!connectionPriorityEnabled, so setConnectionPriorityManagement(enabled = false)
issued NO BLE op at all: turning the toggle off only stopped FUTURE escalations
and left a link already pinned at CONNECTION_PRIORITY_HIGH there until the next
reconnect.

That breaks the toggle's own promise. The Settings copy says to turn it back off
if it costs more battery than it's worth — but a user doing exactly that would
keep paying for it, potentially for hours, since 'Keep connected in the
background' (the switch directly above it) holds the link open.

On a real on->off edge, request BALANCED once to hand the link back to the stack
default. Every other transition still issues nothing — notably the default launch
path, which re-applies enabled=false while already off and stays byte-for-byte
today's zero-BLE-op behaviour.

The edge rule is a pure companion predicate (releasesConnectionPriority),
matching this file's existing idiom (connectionPriorityFor / idleThrottleActive /
offloadIntervalMsFor), since WhoopBleClient needs a Context and can't be built in
the pure-JVM suite. Release swallows throws like the refresh path — a priority
hint must never tear the link down.

The latent flaw is ryanbr#477's, but nothing could reach it while the feature was
dormant; exposing the toggle is what makes the disable path reachable.

ConnectionPriorityTest 11/11 (adds both edge cases); compileFullDebugKotlin +
i18n audit green.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…ce, dormant default-off (ryanbr#477) (ryanbr#478)

* feat(ble): GATT connection-priority management, dormant default-off (ryanbr#477)

Adds the battery lever coordinated in ryanbr#477: request CONNECTION_PRIORITY_HIGH
during an offload burst / live-HR session (a shorter interval → faster sync,
can't cause a supervision-timeout drop) and, behind an opt-in idle throttle,
LOW_POWER when idle. Ships DORMANT — the master gate defaults off, so
refreshConnectionPriority() early-returns and issues zero new BLE ops, leaving
the link at the stack default (BALANCED) exactly as today.

- Pure connectionPriorityFor(offloadActive, liveHrActive, idleThrottleEnabled)
  in the companion (the scanModeForReconnectAttempts idiom), unit-tested.
- GattOps.requestConnectionPriorityCompat seam (RealGattOps delegate).
- Wired at the real transitions: offload begin → HIGH, exitBackfilling → idle,
  reconcileRealtime → HIGH/idle. Reads the authoritative internal flags.
- Android-only by necessity: CoreBluetooth has no app-side connection-priority
  equivalent, so there is no Swift twin — documented divergence, not a gap.

Compiles (:app:compileFullDebugKotlin); ConnectionPriorityTest green. Behaviour
change is gated OFF pending on-strap validation per ryanbr#477.

* fix(ble): connection-priority hint must not trigger link teardown (ryanbr#478 re-review)

refreshConnectionPriority routed the requestConnectionPriority call through
safeGatt, whose policy is 'any throw ⇒ teardownAfterGattFailure()'. That's
right for load-bearing writes/subscriptions but wrong for a battery HINT: a
transient throw would sacrifice the whole connection for an optimization.
Swallow locally instead — a dead binder is handled by the next real op.

* feat(ble): make the idle LOW_POWER throttle battery-adaptive (ryanbr#477)

Instead of a static on/off, the RISKY idle throttle now engages only while the
phone is DISCHARGING and at/below a user-selectable battery %% (picker: 10/15/
20/25/30; 0 = never). Confines the drop-risk to when the user actually wants
power saving. Pure idleThrottleActive(batteryPct, charging, thresholdPct) +
tests; battery read from the sticky ACTION_BATTERY_CHANGED intent (no persistent
receiver), fails SAFE (unknown → 100%%, never throttles). Still dormant behind
the default-off master gate; the safe HIGH-escalation half is unaffected.

* feat(ble): battery-adaptive periodic-offload cadence (ryanbr#477)

Stretch the 15-min periodic offload to 45 min while discharging at/below a
user-selectable battery %% (picker 10/15/20/25/30; 0 = off). The offload tick is
a pure sync timer — the live-stream keep-alive is a separate mechanism — so this
can't affect link health; worst case is data arriving in larger batches (the
strap banks to flash meanwhile, no loss). Pure offloadIntervalMsFor(...) + tests.

Ships DORMANT: threshold defaults 0, and nextBackfillDelayMs early-returns the
normal cadence with ZERO battery reads until opted in. Reuses the ryanbr#478 battery
snapshot; complements the connection-priority lever, lower-risk (no drop risk).

* perf(ble): skip the battery read in safe-half-only mode (ryanbr#478 re-review)

refreshConnectionPriority read the battery on every transition even when the
idle throttle was off (idleThrottleBatteryPct == 0), where the result is always
false. Short-circuit so the SAFE HIGH-escalation half issues no battery read —
mirrors the nextBackfillDelayMs dormancy fix.

* feat(ble): respect Android Battery Saver in the battery-adaptive levers (ryanbr#477)

Both levers (idle LOW_POWER throttle + offload-cadence stretch) now also engage
when the OS Battery Saver is on — the user's explicit 'save power' signal, with
its own hysteresis + charging-awareness. Battery Saver is an OR-WITHIN an
already-armed lever (threshold > 0): it can trigger a lever you opted into at any
battery level, but it can NOT force the risky idle throttle against a deliberate
'off' (threshold 0), respecting the drop-risk asymmetry. powerSave threads through
the pure gates (idleThrottleActive / offloadIntervalMsFor) + tests; read only when
a threshold is armed, so still dormant by default.

* feat(ble): optional pause of continuous-HRV capture under Battery Saver (ryanbr#477)

A separate default-off toggle: while the OS Battery Saver is on, release the
held-open BACKGROUND continuous-capture realtime stream (one of the larger live
drains). A visible Live screen is unaffected (screenWantsRealtime is separate),
and it re-arms automatically when Battery Saver turns off.

Rides the existing ryanbr#927 window-gating machinery: the gate lives in
continuousCaptureWantsNow(), which the 30s keep-alive tick already re-derives and
arms/disarms on the edge — so no new receiver or re-arm path, and no stuck-paused
risk. Dormant by default (pauseCaptureOnPowerSave=false short-circuits the
PowerManager read). setPauseCaptureOnPowerSave reconciles immediately.

* feat(ui): Settings — Power saving section (battery % + HRV pause) (ryanbr#477)

Wires the two BENIGN battery levers to a user-facing Settings section, so the
dormant mechanism is now reachable:
- 'Power saving mode' toggle + a 10/15/20/25/30 %% picker → the offload-cadence
  stretch (setLowBatteryOffloadThrottle). Default off / 20%.
- 'Pause HRV capture in Battery Saver' tick box → the continuous-capture pause
  (setPauseCaptureOnPowerSave). Default off.

Persisted in NoopPrefs; AppViewModel.applyPowerSaving() pushes them at launch and
on every change. The riskier connection-priority idle throttle is deliberately
NOT surfaced — it stays dormant pending on-strap validation (ryanbr#478). Compiles
(:app:compileFullDebugKotlin).

* feat(ui): use a slider for the power-saving battery threshold (ryanbr#477)

Swap the 10/15/20/25/30 pill picker for a stepped Slider (10-30%, snapping to 5%
increments) with a live '20%' read-out. Persists on onValueChangeFinished (not
every drag frame). Default stays 20%.

* feat(ui): HRV pause is a power-saving sub-option, on by default (ryanbr#477)

Make the HRV-capture pause a sub-option of the Power saving master, not an
independent toggle: it shows only when Power saving is on, defaults ON (pref
default flipped to true), and stays user-disableable. Effective HRV pause is now
master && pref, so turning Power saving off disables it too. Resolves the
master/sub-toggle inconsistency from re-review.

* l10n(de): localize the Power saving Settings section (ryanbr#477)

Wire the 7 new Settings strings to R.string.* + German translations, so the ryanbr#452
i18n gate passes (it correctly failed the new hardcoded literals). HRV=HFV per
the existing ryanbr#451 vocabulary; %1$d%% readout keeps the format specifier.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
… priority (ryanbr#533) (ryanbr#536)

* ble: escalate connection priority for the offload burst (ryanbr#533)

ryanbr#477 shipped GATT connection-priority management fully implemented but
DORMANT: setConnectionPriorityManagement was never called from anywhere, so
connectionPriorityEnabled stayed false, refreshConnectionPriority early-returned
at every call site, and EVERY historical offload has run at the stack default.
The escalation hook is already in the right place (enterBackfilling ->
'escalate to HIGH for the offload burst (faster sync)') — nothing switched it on.

Switch on the SAFE half. The offload burst is bounded (HISTORY_COMPLETE / idle
timeout) and bandwidth-hungry, so a shorter interval moves the same bytes in
LESS radio-on wall-clock — the sync gets faster without a proportional energy
cost. The RISKY idle->LOW_POWER half stays at 0 (still dormant, ryanbr#478).

Live-HR deliberately does NOT escalate (new escalateForLiveHr knob, default
off). realtimeArmed is true for the whole OVERNIGHT continuous-HRV window
(22:00-07:00 by default via continuousCaptureWantsNow), not just while a Live
screen is open — escalating it would hold an ~11.25 ms interval for HOURS to
carry a 1 Hz HR/RR stream that BALANCED already serves: sustained drain on both
strap and phone for no throughput gain. The knob keeps the resolver branch live
rather than dead, since the opt-in R22 deep-buffer capture is the one high-rate
live case that could legitimately want HIGH.

NOT VALIDATED ON HARDWARE — I have no strap. BLE behaviour can't be CI- or
Linux-tested, so the speedup and the battery delta both need an on-strap
measurement before this should be trusted. The existing strap log already
timestamps 'Backfill: session started'/'session ended' next to 'persisted N
rows', so records/sec is measurable from a before/after log with no new
instrumentation.

compileFullDebugKotlin + ConnectionPriorityTest (9) green.

* settings: put the faster-offload escalation behind an experimental toggle

Follow-up on the same branch: rather than switching the ryanbr#477 escalation on for
everyone, gate it behind Settings > Strap > 'Faster history sync (experimental)',
default OFF.

BLE behaviour can't be CI- or Linux-tested, so neither the speedup nor the
battery cost can be proven here — an opt-in toggle lets it ship and collect real
field reports instead of blocking on one person's strap, and lets anyone who
finds it unhelpful (or costly) turn it straight back off. Matches how the other
unvalidated levers are handled.

- NoopPrefs.KEY_FAST_HISTORY_SYNC (default false) + getter/setter
- AppViewModel.setFastHistorySync persists + re-applies immediately, so the next
  offload burst picks it up without a reconnect; applyPowerSaving now reads the
  pref instead of hardcoding enabled=true. NOT gated on the Power-saving master
  (this spends radio for speed; it is not a power-saving lever)
- Settings toggle lives in the Strap card next to 'Keep connected in the
  background', not in Power saving (a card about saving power is the wrong home
  for a switch that spends it)
- strings added to values + de/es/fr (the coverage half of the gate)

i18n audit OK (no hardcoded literals, all focus locales); compileFullDebugKotlin
+ ConnectionPriorityTest (9) green.

* ble: release the link back to default when the sync experiment is turned off

Re-review catch on my own change. refreshConnectionPriority() early-returns on
!connectionPriorityEnabled, so setConnectionPriorityManagement(enabled = false)
issued NO BLE op at all: turning the toggle off only stopped FUTURE escalations
and left a link already pinned at CONNECTION_PRIORITY_HIGH there until the next
reconnect.

That breaks the toggle's own promise. The Settings copy says to turn it back off
if it costs more battery than it's worth — but a user doing exactly that would
keep paying for it, potentially for hours, since 'Keep connected in the
background' (the switch directly above it) holds the link open.

On a real on->off edge, request BALANCED once to hand the link back to the stack
default. Every other transition still issues nothing — notably the default launch
path, which re-applies enabled=false while already off and stays byte-for-byte
today's zero-BLE-op behaviour.

The edge rule is a pure companion predicate (releasesConnectionPriority),
matching this file's existing idiom (connectionPriorityFor / idleThrottleActive /
offloadIntervalMsFor), since WhoopBleClient needs a Context and can't be built in
the pure-JVM suite. Release swallows throws like the refresh path — a priority
hint must never tear the link down.

The latent flaw is ryanbr#477's, but nothing could reach it while the feature was
dormant; exposing the toggle is what makes the disable path reachable.

ConnectionPriorityTest 11/11 (adds both edge cases); compileFullDebugKotlin +
i18n audit green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant