Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 54 additions & 4 deletions android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,15 @@ class WhoopBleClient(
fun idleThrottleActive(batteryPct: Int, charging: Boolean, thresholdPct: Int): Boolean =
thresholdPct > 0 && !charging && batteryPct <= thresholdPct

/** #533: whether flipping connection-priority management from [wasEnabled] to [nowEnabled] must
* RELEASE the link back to the stack default. ONLY the on→off edge does: [refreshConnectionPriority]
* early-returns once disabled, so without an explicit release a link currently pinned at HIGH would
* stay there until the next reconnect — a user turning the experiment off *because* of battery would
* keep paying for it. Enabling, or re-applying while already off (every launch on the default), must
* issue no request at all. */
fun releasesConnectionPriority(wasEnabled: Boolean, nowEnabled: Boolean): Boolean =
wasEnabled && !nowEnabled

/** Stretched periodic-offload interval while the STRAP is low on battery (#477). The offload tick
* is a PURE sync timer (the live-stream keep-alive is separate), so stretching it can't affect
* link health — worst case is data arriving in slightly larger batches; the strap banks
Expand Down Expand Up @@ -1195,12 +1204,50 @@ class WhoopBleClient(
* half only). The Settings picker offers 10/15/20/25/30. */
@Volatile private var idleThrottleBatteryPct: Int = 0

/** #533: also escalate to HIGH for the LIVE-HR stream, not just the offload burst. DEFAULT OFF, and
* deliberately so: [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 — a sustained drain on both strap and phone for no throughput gain. The offload burst is the
* opposite: bounded (HISTORY_COMPLETE / idle timeout) and bandwidth-hungry, so escalating it moves the
* same bytes in LESS radio-on wall-clock. Kept as a knob rather than deleted because the opt-in R22
* deep-buffer capture IS high-rate and is the one live case that could legitimately want HIGH. */
@Volatile private var escalateForLiveHr: Boolean = false

/** Opt into connection-priority management (#477). No-op by default; see the fields above.
* [idleThrottleBatteryPct] 0 disables the risky idle throttle (safe half only). */
fun setConnectionPriorityManagement(enabled: Boolean, idleThrottleBatteryPct: Int) {
* [idleThrottleBatteryPct] 0 disables the risky idle throttle (safe half only).
* [escalateForLiveHr] false keeps the escalation to the bounded offload burst (#533). */
fun setConnectionPriorityManagement(
enabled: Boolean,
idleThrottleBatteryPct: Int,
escalateForLiveHr: Boolean = false,
) {
val wasEnabled = connectionPriorityEnabled
connectionPriorityEnabled = enabled
this.idleThrottleBatteryPct = if (enabled) idleThrottleBatteryPct else 0
handler.post { refreshConnectionPriority() }
this.escalateForLiveHr = enabled && escalateForLiveHr
handler.post {
// #533: switching the experiment OFF must UNDO a live escalation, not merely stop future ones.
// [refreshConnectionPriority] early-returns on !connectionPriorityEnabled, so without this a
// link currently pinned at HIGH would STAY there until the next reconnect — a user turning the
// toggle off *because* of battery would keep paying for it, potentially for hours on a
// background connection. Only fires on a real on→off edge; enabling (or a no-op re-apply while
// already off) never issues a stray request. See [releasesConnectionPriority].
if (releasesConnectionPriority(wasEnabled, enabled)) releaseConnectionPriority()
else refreshConnectionPriority()
}
}

/** #533: hand the link back to the stack default (BALANCED) when connection-priority management is
* switched off, undoing any escalation still in force. Same swallow-don't-teardown policy as
* [refreshConnectionPriority]: a priority hint must never drop the link. */
private fun releaseConnectionPriority() {
val ops = gattOps ?: return
try {
ops.requestConnectionPriorityCompat(BluetoothGatt.CONNECTION_PRIORITY_BALANCED)
} catch (t: Throwable) {
log("connection-priority release failed (${t.javaClass.simpleName}); skipped")
}
}

/** Battery-% at/below which the periodic offload cadence stretches to
Expand Down Expand Up @@ -1266,7 +1313,10 @@ class WhoopBleClient(
// published LiveState mirror, which `exitBackfilling` may update a beat later.
val priority = connectionPriorityFor(
offloadActive = backfilling,
liveHrActive = realtimeArmed,
// #533: gated — the live stream does NOT escalate by default. See [escalateForLiveHr]: the
// overnight continuous-HRV window keeps this armed for hours, and a 1 Hz stream gains nothing
// from HIGH. The offload burst below is the case that actually wants the shorter interval.
liveHrActive = realtimeArmed && escalateForLiveHr,
idleThrottleEnabled = idleThrottle,
)
// Deliberately NOT via safeGatt: a battery HINT must never tear the link down. safeGatt's policy
Expand Down
27 changes: 23 additions & 4 deletions android/app/src/main/java/com/noop/ui/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -892,12 +892,24 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
autoReconnectOnLaunch()
}

/** Push the persisted #477 Power-saving prefs to the BLE client. The offload-cadence stretch uses the
* battery-% threshold (0 = off when the master is off); the HRV pause is its own Battery-Saver toggle.
* The riskier connection-priority idle throttle is deliberately NOT exposed here — it stays dormant
* pending on-strap validation (#478). */
/** Push the persisted BLE-behaviour prefs to the client. The #477 Power-saving levers: the
* offload-cadence stretch uses the battery-% threshold (0 = off when the master is off); the HRV pause
* is its own Battery-Saver toggle. The riskier connection-priority idle throttle is deliberately NOT
* exposed here — it stays dormant pending on-strap validation (#478). Also pushes the independent
* #533 "Faster history sync" experiment (its own toggle, NOT gated on the Power-saving master: it is a
* sync-speed lever, not a power-saving one). */
private fun applyPowerSaving() {
val on = NoopPrefs.powerSaving(appContext)
// #533: the SAFE half of #477's connection-priority management shipped fully implemented but
// DORMANT — nothing ever called this, so refreshConnectionPriority early-returned and EVERY
// historical offload ran at the stack default. Behind the experimental toggle it escalates to HIGH
// for the bounded offload burst (faster backlog drain). The RISKY idle→LOW_POWER half stays at 0
// (still dormant, #478), and live-HR does not escalate (see WhoopBleClient.escalateForLiveHr) —
// realtimeArmed covers the overnight capture window, which would otherwise hold HIGH for hours.
ble.setConnectionPriorityManagement(
enabled = NoopPrefs.fastHistorySync(appContext),
idleThrottleBatteryPct = 0,
)
ble.setLowBatteryOffloadThrottle(if (on) NoopPrefs.powerSavingBatteryPct(appContext) else 0)
// HRV pause is a sub-option: only effective while the master is on (defaults on when it is), and
// now battery-%-aware like the offload lever — pass the same threshold.
Expand Down Expand Up @@ -925,6 +937,13 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
applyPowerSaving()
}

/** Flip the experimental "Faster history sync" (#533). Persists + applies immediately, so the next
* offload burst uses the new priority without waiting for a reconnect. */
fun setFastHistorySync(enabled: Boolean) {
NoopPrefs.setFastHistorySync(appContext, enabled)
applyPowerSaving()
}

/** The effective continuous-HRV want: the user's "Continuous HRV capture" preference AND
* "Keep connected in the background" — the latter is what holds the link up for the stream to ride,
* so continuous capture is meaningless without it. */
Expand Down
17 changes: 17 additions & 0 deletions android/app/src/main/java/com/noop/ui/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,23 @@ object NoopPrefs {
of(context).edit().putBoolean(KEY_PAUSE_HRV_ON_POWER_SAVE, enabled).apply()
}

/** EXPERIMENTAL (#533): escalate the GATT connection interval to HIGH for the bounded historical
* offload burst, so a large backlog drains faster. Drives the SAFE half of #477's connection-priority
* management via [com.noop.ble.WhoopBleClient.setConnectionPriorityManagement]; the risky idle
* throttle stays off, and the live/overnight stream never escalates.
*
* DEFAULT OFF and behind an "(experimental)" label on purpose: BLE behaviour cannot be CI- or
* Linux-tested, so both the speedup and its battery cost need real-strap field reports before this
* could ever be considered for default-on. */
const val KEY_FAST_HISTORY_SYNC = "noop.fastHistorySync"

fun fastHistorySync(context: Context): Boolean =
of(context).getBoolean(KEY_FAST_HISTORY_SYNC, false)

fun setFastHistorySync(context: Context, enabled: Boolean) {
of(context).edit().putBoolean(KEY_FAST_HISTORY_SYNC, enabled).apply()
}

/** #836, the raw-HR fingerprint ("count:maxTs") the last COMPLETED idle rescore scored against. The
* 15-min backstop tick skips when the current fingerprint equals this; cleared implicitly by any HR
* insert/delete (the fingerprint moves). Mirrors the Swift `analyzeWatermark` UserDefaults key. */
Expand Down
38 changes: 38 additions & 0 deletions android/app/src/main/java/com/noop/ui/SettingsScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,7 @@ fun SettingsScreen(
// "Keep connected in the background" — drives WhoopConnectionService (foreground service). Default
// on. SharedPreferences isn't reactive, so the Switch mirrors into a local state.
var backgroundConnection by remember { mutableStateOf(NoopPrefs.backgroundConnection(context)) }
var fastHistorySync by remember { mutableStateOf(NoopPrefs.fastHistorySync(context)) }

// "Continuous HRV capture" — hold the dense realtime stream armed 24/7 (better overnight HRV) at the
// cost of more battery. Default OFF; only does anything with background connection on. Local mirror.
Expand Down Expand Up @@ -1280,6 +1281,43 @@ fun SettingsScreen(
)
}

// "Faster history sync" (#533, EXPERIMENTAL): asks Android for a shorter GATT connection
// interval for the BOUNDED historical-offload burst only. Off by default — BLE behaviour
// can't be CI-tested, so this needs real-strap field reports on both the speedup and the
// battery cost. The live/overnight stream deliberately never escalates.
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
stringResource(R.string.fast_history_sync),
style = NoopType.subhead,
color = Palette.textPrimary,
)
Text(
stringResource(R.string.fast_history_sync_desc),
style = NoopType.footnote,
color = Palette.textTertiary,
)
}
Switch(
checked = fastHistorySync,
onCheckedChange = {
fastHistorySync = it
vm.setFastHistorySync(it)
},
colors = SwitchDefaults.colors(
checkedThumbColor = Palette.surfaceBase,
checkedTrackColor = Palette.accent,
uncheckedThumbColor = Palette.textSecondary,
uncheckedTrackColor = Palette.surfaceInset,
uncheckedBorderColor = Palette.hairline,
),
)
}

// "Keep NOOP alive overnight" (#386): the battery-optimisation whitelist. Shown ONLY while
// background connection is on (meaningless otherwise), so it never adds noise on a
// foreground-only setup. `checked` reflects the LIVE system exempt state, so an already-exempt
Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@
<string name="timeline_other_sources_no_offload">Andere Quellen übertragen keine rohen Sekundendaten auf das Gerät.</string>

<!-- Power saving (#477) -->
<string name="fast_history_sync">Schnellere Verlaufssynchronisierung (experimentell)</string>
<string name="fast_history_sync_desc">Fordert eine schnellere Bluetooth-Verbindung an, während Ihr Riemen seinen gespeicherten Verlauf überträgt, damit ein großer Rückstand schneller aufholt. Nur während der Synchronisierung — Ihre Live-Herzfrequenz und der nächtliche Datenstrom bleiben unberührt. Neu und unerprobt: Wenn es nicht hilft oder zu viel Akku kostet, schalten Sie es wieder aus.</string>
<string name="power_saving">Energie sparen</string>
<string name="power_saving_blurb">Entlastet dein Band, wenn sein Akku zur Neige geht. Das Band speichert die Daten selbst, es geht also nichts verloren – NOOP kommuniziert nur seltener mit ihm, damit es durchhält, bis du es laden kannst.</string>
<string name="power_saving_mode">Energiesparmodus</string>
Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/res/values-es/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1591,6 +1591,8 @@
<string name="timeline_empty_metric">No %1$s aquí</string>
<string name="timeline_nothing_offloaded">Aún no se ha descargado nada para esta ventana.</string>
<string name="timeline_other_sources_no_offload">Otras fuentes no descargan datos brutos por segundo en el dispositivo.</string>
<string name="fast_history_sync">Sincronización de historial más rápida (experimental)</string>
<string name="fast_history_sync_desc">Solicita una conexión Bluetooth más rápida mientras su correa transfiere su historial almacenado, para que un gran retraso se ponga al día antes. Solo durante la sincronización: su frecuencia cardíaca en vivo y el flujo nocturno no se ven afectados. Nuevo y no probado: si no ayuda o consume demasiada batería, vuelva a desactivarlo.</string>
<string name="power_saving">Salvamento de energía</string>
<string name="power_saving_blurb">Facilite la carga en la correa cuando su batería esté baja. La correa mantiene datos bancarios por sí solas, por lo que nada se pierde — NOOP sólo habla con ella con menos frecuencia para ayudarle a durar hasta que pueda cargarlo.</string>
<string name="power_saving_mode">Modo de ahorro de energía</string>
Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/res/values-fr/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1591,6 +1591,8 @@
<string name="timeline_empty_metric">Pas de %1$s ici</string>
<string name="timeline_nothing_offloaded">Rien de déchargé pour cette fenêtre pour l\'instant.</string>
<string name="timeline_other_sources_no_offload">D\'autres sources ne déchargent pas les données brutes par seconde sur les appareils.</string>
<string name="fast_history_sync">Synchronisation de l\'historique plus rapide (expérimental)</string>
<string name="fast_history_sync_desc">Demande une connexion Bluetooth plus rapide pendant que votre sangle transfère son historique stocké, afin qu\'un gros retard se rattrape plus vite. Uniquement pendant la synchronisation : votre fréquence cardiaque en direct et le flux nocturne ne sont pas affectés. Nouveau et non éprouvé : si cela n\'aide pas ou consomme trop de batterie, désactivez-le.</string>
<string name="power_saving">Économie d\'énergie</string>
<string name="power_saving_blurb">Diminuer la charge sur votre sangle lorsque sa batterie est basse. La sangle garde les données bancaires à elle seule, donc rien n\'est perdu — NOOP lui parle moins souvent pour l\'aider jusqu\'à ce que vous puissiez les charger.</string>
<string name="power_saving_mode">Mode d\'économie d\'énergie</string>
Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@
<string name="timeline_other_sources_no_offload">Other sources don\'t offload raw per-second data on-device.</string>

<!-- Power saving (#477) -->
<string name="fast_history_sync">Faster history sync (experimental)</string>
<string name="fast_history_sync_desc">Ask Android for a faster Bluetooth link while your strap hands over its stored history, so a big backlog catches up sooner. Only during the sync itself — your live heart rate and the overnight stream are untouched. New and unproven: if it doesn\'t help, or costs more battery than it\'s worth, turn it back off and say so on the issue tracker.</string>
<string name="power_saving">Power saving</string>
<string name="power_saving_blurb">Ease the load on your strap when its battery is running low. The strap keeps banking data on its own, so nothing is lost — NOOP just talks to it less often to help it last until you can charge it.</string>
<string name="power_saving_mode">Power saving mode</string>
Expand Down
Loading
Loading