Repository navigation
feat(ble): battery-adaptive BLE — connection-priority + offload-cadence, dormant default-off (#477) - #478
Conversation
…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.
|
Re-review found and fixed one real bug (a50de0a): The battery hint could tear down the connection. Also reviewed and accepted as-is: the non- |
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).
|
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 (
Both levers compile ( |
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.
|
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: iOS parity scope (deliberate, stated). Of the three levers:
|
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).
|
Added the Settings UI — the feature is now user-reachable (was headless mechanism-only before). Settings → Power saving (a new everyday section, above Advanced):
Persisted in 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 ( |
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.
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.
… 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.
…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.
… 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.
…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.
… 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.
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_HIGHduring an offload burst / live-HR session, and — behind an opt-in idle throttle —CONNECTION_PRIORITY_LOW_POWERwhen the connection is idle. Today the WHOOP link runs at the defaultBALANCEDfor its whole lifetime (requestConnectionPriorityis never called), even though it's idle most of the day.Safe / risky split (per #477)
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
connectionPriorityEnableddefaults false, sorefreshConnectionPriority()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
connectionPriorityFor(offloadActive, liveHrActive, idleThrottleEnabled)in the companion — thescanModeForReconnectAttempts(Repository.mergeActivityFileSteps: graceful last-wins on duplicate day (parity + no trap) #588) idiom, unit-testable with no BLE stack.GattOps.requestConnectionPriorityCompatadded to the existing injectable seam (RealGattOpsdelegates togatt.requestConnectionPriority).begin→ HIGH,exitBackfilling→ idle,reconcileRealtime→ HIGH/idle. Reads the authoritative internal flags (backfilling/realtimeArmed), not the publishedLiveStatemirror 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
connectionPriorityFordoc, 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.Do not flip the toggle on / promote to default-on until that hardware trial is done.