diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift
index d6dd454e27..5de1ff530c 100644
--- a/Strand/App/AppModel.swift
+++ b/Strand/App/AppModel.swift
@@ -462,6 +462,8 @@ final class AppModel: ObservableObject {
/// The riskier connection-priority idle throttle is intentionally not wired (Android-only, and dormant).
func applyPowerSaving() {
let on = PuffinExperiment.powerSavingEnabled
+ // Sub-option: only in effect while the Power-saving master is on, like the HRV-pause lever below.
+ ble.setLowRefreshMode(on && PuffinExperiment.lowRefreshEnabled)
ble.setLowBatteryOffloadThrottle(on ? PuffinExperiment.powerSavingBatteryPct : 0)
// HRV pause is battery-%-aware like the offload lever — pass the same threshold.
ble.setPauseCaptureOnPowerSave(on && PuffinExperiment.pauseHrvOnPowerSaveEnabled,
diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift
index b5de98578e..9c4c2ca9b4 100644
--- a/Strand/BLE/BLEManager.swift
+++ b/Strand/BLE/BLEManager.swift
@@ -537,6 +537,8 @@ public final class BLEManager: NSObject, ObservableObject {
/// high-freq-sync), so each periodic tick just routes through requestSync(.periodic) → beginBackfill
/// (SEND_HISTORICAL_DATA + watchdog), subject to the BackfillPolicy floor.
private var backfillTimer: DispatchSourceTimer?
+ /// User-elected hourly background-sync cadence (Settings → Power saving → "Low refresh"). Default off.
+ private var lowRefreshMode = false
// The timer fires this often, but BackfillPolicy.periodicFloorSeconds is the real floor (a recent
// event-triggered sync defers the next periodic tick). 900s = 15 min, matching WHOOP.
static let backfillIntervalSeconds = 900
@@ -548,6 +550,15 @@ public final class BLEManager: NSObject, ObservableObject {
/// so this only delays sync (larger batches), never loses data. Mirrors Android
/// `LOW_BATTERY_BACKFILL_INTERVAL_MS`.
static let lowBatteryBackfillIntervalSeconds = 2700
+ /// Low-refresh cadence (60 min): the user-elected sub-option of Power saving. Unlike the battery
+ /// lever above it is NOT battery-gated — once chosen it is the baseline the other levers stretch
+ /// FROM, so a quiet strap stays quiet at any charge. Same no-loss property: the strap banks to flash
+ /// and only trims on our ack, so this delays sync into larger batches, it never drops history.
+ /// Deliberately cadence-ONLY: it does not touch the keep-alive (that tick re-arms the WHOOP 4
+ /// realtime burst every cycle and evaluates the 120 s stall fuse — see `startKeepAlive`) and it does
+ /// not touch continuous HRV capture (that is what "Pause HRV capture" and #927's overnight window
+ /// are for). Fewer periodic offloads = fewer reconnect bursts, which is the measured 4.0 drain.
+ static let lowRefreshBackfillIntervalSeconds = 3600
/// Pure battery-adaptive gate (#477), the twin of Android `WhoopBleClient.idleThrottleActive`. Keyed
/// on the STRAP's battery: armed by `thresholdPct` > 0, engages while the strap is discharging at/below
@@ -563,6 +574,13 @@ public final class BLEManager: NSObject, ObservableObject {
? max(baseSeconds, lowSeconds) : baseSeconds
}
+ /// Pure baseline-cadence decision: low refresh replaces the 15-min BASE with the hourly one, and every
+ /// other lever composes on top with `max`, so a lever can only ever make the cadence QUIETER, never
+ /// restore a faster one the user asked to slow down. Unit-testable without a CoreBluetooth seam.
+ static func baseBackfillInterval(lowRefresh: Bool) -> Int {
+ lowRefresh ? lowRefreshBackfillIntervalSeconds : backfillIntervalSeconds
+ }
+
/// #battery: pure 5/MG battery-read throttle decision, unit-testable without a CoreBluetooth seam.
/// Returns true when no prior read exists (the first read of a connection, or post-disconnect re-seed)
/// OR when at least `whoop5BatteryReadMinIntervalSeconds` has elapsed since the last read. Stops the
@@ -2502,6 +2520,13 @@ public final class BLEManager: NSObject, ObservableObject {
lowBatteryOffloadPct = thresholdPct
}
+ /// Settings sub-option of Power saving: the user-elected hourly background cadence. Applies on the
+ /// NEXT re-arm exactly like the battery lever beside it, so a sync already in flight is never
+ /// interrupted (the cadence is re-read at each re-arm). Twin of Android `setLowRefreshMode`.
+ public func setLowRefreshMode(_ enabled: Bool) {
+ lowRefreshMode = enabled
+ }
+
/// #477 (Settings): pause the background continuous-HRV stream when the strap is low. Keyed on the
/// STRAP's battery like the offload lever — pass the same threshold; engages at/below it (0 = off).
/// Reconciles now.
@@ -2530,19 +2555,22 @@ public final class BLEManager: NSObject, ObservableObject {
/// tracker's quietThreshold is 2) and at a fixed 45-min floor that stacks with it. Twin of Android
/// `nextBackfillDelayMs`. Resets with `whoop5EmptyOffload` on disconnect (a fresh connect re-probes).
private func nextBackfillInterval() -> Int {
+ // Low refresh moves the BASE the other levers stretch from; each one composes with `max`, so the
+ // cadence can only get quieter, never faster than the user asked for.
+ let base = BLEManager.baseBackfillInterval(lowRefresh: lowRefreshMode)
// #battery: known-empty-history 5/MG → stretch to the 45-min floor before any battery lever.
if selectedModel.deviceFamily == .whoop5 {
let stretched = BLEManager.whoop5EmptyHistoryBackfillInterval(
- baseSeconds: BLEManager.backfillIntervalSeconds,
- lowSeconds: BLEManager.lowBatteryBackfillIntervalSeconds,
+ baseSeconds: base,
+ lowSeconds: max(base, BLEManager.lowBatteryBackfillIntervalSeconds),
historyEmpty: whoop5EmptyOffload.historyEmpty)
- if stretched != BLEManager.backfillIntervalSeconds { return stretched }
+ if stretched != base { return stretched }
}
- guard lowBatteryOffloadPct > 0 else { return BLEManager.backfillIntervalSeconds }
+ guard lowBatteryOffloadPct > 0 else { return base }
let (pct, charging) = batteryPctAndCharging()
return BLEManager.offloadInterval(
- baseSeconds: BLEManager.backfillIntervalSeconds,
- lowSeconds: BLEManager.lowBatteryBackfillIntervalSeconds,
+ baseSeconds: base,
+ lowSeconds: max(base, BLEManager.lowBatteryBackfillIntervalSeconds),
batteryPct: pct, charging: charging, thresholdPct: lowBatteryOffloadPct)
}
diff --git a/Strand/BLE/PuffinExperiment.swift b/Strand/BLE/PuffinExperiment.swift
index db52147b74..b6865ba874 100644
--- a/Strand/BLE/PuffinExperiment.swift
+++ b/Strand/BLE/PuffinExperiment.swift
@@ -158,6 +158,12 @@ enum PuffinExperiment {
static let powerSavingKey = "noopPowerSaving"
static var powerSavingEnabled: Bool { UserDefaults.standard.bool(forKey: powerSavingKey) }
+ /// "Low refresh": a sub-option of Power saving (only meaningful while that master is on). Stretches
+ /// the periodic history offload to hourly at ANY strap charge, instead of only while the battery is
+ /// low. Default off. Cadence only — no data is lost (the strap banks to flash and trims on our ack).
+ static let lowRefreshKey = "noopLowRefresh"
+ static var lowRefreshEnabled: Bool { UserDefaults.standard.bool(forKey: lowRefreshKey) }
+
/// Battery-% threshold for power saving (10–30). Default 20 (0 in the store means "unset" → 20).
static let powerSavingBatteryPctKey = "noopPowerSavingBatteryPct"
static var powerSavingBatteryPct: Int {
diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings
index 9dc702f252..19598ca68e 100644
--- a/Strand/Resources/Localizable.xcstrings
+++ b/Strand/Resources/Localizable.xcstrings
@@ -207150,6 +207150,74 @@
}
}
}
+ },
+ "Low refresh": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Seltener aktualisieren"
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Actualización lenta"
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Rafraîchissement réduit"
+ }
+ },
+ "pt-PT": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Atualização reduzida"
+ }
+ },
+ "pl": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Rzadsze odświeżanie"
+ }
+ }
+ }
+ },
+ "Sync in the background every hour instead of every 15 minutes, whatever the strap's charge — fewer reconnections is the biggest saving on a WHOOP 4.0. Nothing is lost: the strap banks everything and hands it over in larger batches. Pull to sync still runs straight away, and live heart rate is untouched.": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Synchronisiert im Hintergrund stündlich statt alle 15 Minuten, unabhängig vom Ladestand — weniger Verbindungsaufbauten sind die größte Ersparnis bei einer WHOOP 4.0. Es geht nichts verloren: Das Band speichert alles und übergibt es in größeren Paketen. Manuelles Synchronisieren läuft weiterhin sofort, die Live-Herzfrequenz bleibt unberührt."
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Sincroniza en segundo plano cada hora en vez de cada 15 minutos, sea cual sea la carga de la banda: menos reconexiones es el mayor ahorro en una WHOOP 4.0. No se pierde nada: la banda lo guarda todo y lo entrega en lotes más grandes. Sincronizar manualmente sigue siendo inmediato y la frecuencia cardíaca en vivo no cambia."
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Synchronise en arrière-plan toutes les heures au lieu de toutes les 15 minutes, quelle que soit la charge du bracelet : moins de reconnexions, c'est la plus grosse économie sur une WHOOP 4.0. Rien n'est perdu : le bracelet enregistre tout et le transmet par lots plus importants. La synchro manuelle reste immédiate et la fréquence cardiaque en direct n'est pas affectée."
+ }
+ },
+ "pt-PT": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Sincroniza em segundo plano a cada hora em vez de a cada 15 minutos, seja qual for a carga da pulseira — menos religações é a maior poupança numa WHOOP 4.0. Nada se perde: a pulseira guarda tudo e entrega em lotes maiores. A sincronização manual continua imediata e a frequência cardíaca em direto não é afetada."
+ }
+ },
+ "pl": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Synchronizuje w tle co godzinę zamiast co 15 minut, niezależnie od poziomu naładowania opaski — mniej ponownych połączeń to największa oszczędność w WHOOP 4.0. Nic nie ginie: opaska zapisuje wszystko i przekazuje w większych paczkach. Ręczna synchronizacja nadal działa od razu, a tętno na żywo pozostaje bez zmian."
+ }
+ }
+ }
}
},
"version": "1.0"
diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift
index 6af95ee82b..f6d76e3e1a 100644
--- a/Strand/Screens/SettingsView.swift
+++ b/Strand/Screens/SettingsView.swift
@@ -147,6 +147,7 @@ struct SettingsView: View {
// #477 Power saving (parity with Android). Battery-adaptive sync cadence + an HRV-pause sub-option.
@AppStorage(PuffinExperiment.powerSavingKey) private var powerSavingEnabled = false
+ @AppStorage(PuffinExperiment.lowRefreshKey) private var lowRefreshEnabled = false
@AppStorage(PuffinExperiment.powerSavingBatteryPctKey) private var powerSavingPct = 20
/// Stored INVERTED so the default (absent = false) reads as "HRV pause on". The toggle shows `!this`.
@AppStorage(PuffinExperiment.pauseHrvDisabledKey) private var pauseHrvDisabled = false
@@ -1446,6 +1447,21 @@ struct SettingsView: View {
.font(StrandFont.caption)
.foregroundStyle(StrandPalette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
+
+ rowDivider
+ // Low refresh: a sub-option that applies at ANY charge, not just below the threshold.
+ Toggle(isOn: $lowRefreshEnabled) {
+ Text("Low refresh")
+ .font(StrandFont.subhead)
+ .foregroundStyle(StrandPalette.textPrimary)
+ }
+ .toggleStyle(.switch)
+ .tint(StrandPalette.accent)
+ .onChangeCompat(of: lowRefreshEnabled) { _ in model.applyPowerSaving() }
+ Text("Sync in the background every hour instead of every 15 minutes, whatever the strap's charge — fewer reconnections is the biggest saving on a WHOOP 4.0. Nothing is lost: the strap banks everything and hands it over in larger batches. Pull to sync still runs straight away, and live heart rate is untouched.")
+ .font(StrandFont.caption)
+ .foregroundStyle(StrandPalette.textTertiary)
+ .fixedSize(horizontal: false, vertical: true)
}
}
}
diff --git a/StrandTests/LowRefreshCadenceTests.swift b/StrandTests/LowRefreshCadenceTests.swift
new file mode 100644
index 0000000000..feac2decfb
--- /dev/null
+++ b/StrandTests/LowRefreshCadenceTests.swift
@@ -0,0 +1,69 @@
+import XCTest
+@testable import Strand
+
+/// "Low refresh" (Settings → Power saving sub-option) moves the BASE periodic-offload cadence from 15 min
+/// to 60 min. The invariant that matters is COMPOSITION: every other lever stretches FROM that base with
+/// `max`, so no lever can ever hand back a faster cadence than the user asked for, and a lever that would
+/// have stretched a 15-min base is a no-op once the base is already longer.
+///
+/// Cadence only, by design: low refresh deliberately does NOT touch the keep-alive (that tick re-arms the
+/// WHOOP 4 realtime burst each cycle and evaluates the 120 s stall fuse) and does NOT release continuous
+/// HRV capture (that is the separate "Pause HRV capture" lever / #927's overnight window). Those are the
+/// two places a quieter radio would cost real data rather than merely delaying it.
+//
+// BLEManager is @MainActor, so its static helpers are main-actor-isolated; the test methods must run on
+// the main actor to call them (matches Whoop5BatteryBackfillThrottleTests etc.). Class-level @MainActor
+// covers all.
+@MainActor
+final class LowRefreshCadenceTests: XCTestCase {
+
+ func testBaseIsFifteenMinutesWhenOff() {
+ XCTAssertEqual(BLEManager.baseBackfillInterval(lowRefresh: false), BLEManager.backfillIntervalSeconds)
+ XCTAssertEqual(BLEManager.backfillIntervalSeconds, 900)
+ }
+
+ func testBaseIsHourlyWhenOn() {
+ XCTAssertEqual(BLEManager.baseBackfillInterval(lowRefresh: true), BLEManager.lowRefreshBackfillIntervalSeconds)
+ XCTAssertEqual(BLEManager.lowRefreshBackfillIntervalSeconds, 3600)
+ }
+
+ /// The low-battery lever (45 min) is SHORTER than low refresh (60 min): composed with `max` it must
+ /// leave the hourly cadence alone rather than speeding it back up.
+ func testLowBatteryLeverNeverShortensLowRefresh() {
+ let base = BLEManager.baseBackfillInterval(lowRefresh: true)
+ let composed = BLEManager.offloadInterval(
+ baseSeconds: base,
+ lowSeconds: max(base, BLEManager.lowBatteryBackfillIntervalSeconds),
+ batteryPct: 10, charging: false, thresholdPct: 20) // lever fully engaged
+ XCTAssertEqual(composed, BLEManager.lowRefreshBackfillIntervalSeconds)
+ }
+
+ /// Without low refresh the same engaged lever still stretches 15 → 45 min, i.e. this change is inert
+ /// for everyone who does not turn it on.
+ func testDefaultBehaviourIsUnchangedWhenOff() {
+ let base = BLEManager.baseBackfillInterval(lowRefresh: false)
+ XCTAssertEqual(
+ BLEManager.offloadInterval(baseSeconds: base,
+ lowSeconds: max(base, BLEManager.lowBatteryBackfillIntervalSeconds),
+ batteryPct: 10, charging: false, thresholdPct: 20),
+ BLEManager.lowBatteryBackfillIntervalSeconds)
+ // …and an idle/charged strap keeps the plain 15-min cadence.
+ XCTAssertEqual(
+ BLEManager.offloadInterval(baseSeconds: base,
+ lowSeconds: max(base, BLEManager.lowBatteryBackfillIntervalSeconds),
+ batteryPct: 90, charging: false, thresholdPct: 20),
+ BLEManager.backfillIntervalSeconds)
+ }
+
+ /// The 5/MG empty-history stretch (45 min) composes the same way: it may quieten a 15-min base, but
+ /// must not pull an already-hourly cadence back down.
+ func testWhoop5EmptyHistoryStretchNeverShortensLowRefresh() {
+ let base = BLEManager.baseBackfillInterval(lowRefresh: true)
+ XCTAssertEqual(
+ BLEManager.whoop5EmptyHistoryBackfillInterval(
+ baseSeconds: base,
+ lowSeconds: max(base, BLEManager.lowBatteryBackfillIntervalSeconds),
+ historyEmpty: true),
+ BLEManager.lowRefreshBackfillIntervalSeconds)
+ }
+}
diff --git a/Tools/i18n_extra_locale_baseline.txt b/Tools/i18n_extra_locale_baseline.txt
index 02784c01c1..cbd4d3066f 100644
--- a/Tools/i18n_extra_locale_baseline.txt
+++ b/Tools/i18n_extra_locale_baseline.txt
@@ -14,8 +14,8 @@ NOOPWatch/Localizable.xcstrings:zh-Hant 0
NOOPWatchComplications/Localizable.xcstrings:it 25
NOOPWatchComplications/Localizable.xcstrings:zh-Hans 25
NOOPWatchComplications/Localizable.xcstrings:zh-Hant 25
-Strand/Resources/Localizable.xcstrings:it 177
-Strand/Resources/Localizable.xcstrings:ru 154
-Strand/Resources/Localizable.xcstrings:zh-Hans 127
-Strand/Resources/Localizable.xcstrings:zh-Hant 177
+Strand/Resources/Localizable.xcstrings:it 179
+Strand/Resources/Localizable.xcstrings:ru 156
+Strand/Resources/Localizable.xcstrings:zh-Hans 129
+Strand/Resources/Localizable.xcstrings:zh-Hant 179
values-zh/strings.xml 50
diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
index 3029d977c2..a19c112804 100644
--- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
+++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
@@ -717,6 +717,21 @@ class WhoopBleClient(
* meanwhile, so this only delays sync (larger batches), never loses data. Gated on the discharging
* battery-% threshold; 0 = disabled → always [BACKFILL_INTERVAL_MS]. */
private const val LOW_BATTERY_BACKFILL_INTERVAL_MS = 2_700_000L
+
+ /** Low-refresh cadence (60 min): the user-elected sub-option of Power saving. NOT battery-gated —
+ * once chosen it is the BASE the other levers stretch from, at any strap charge. Same no-loss
+ * property as the lever above: the strap banks to flash and only trims on our ack, so this delays
+ * sync into larger batches, it never drops history. Cadence ONLY — deliberately does not touch the
+ * keep-alive (that tick re-arms realtime and evaluates the stall fuse) or continuous HRV capture
+ * (that is [setPauseCaptureOnPowerSave] / the overnight window). Twin of Swift
+ * `BLEManager.lowRefreshBackfillIntervalSeconds`. */
+ internal const val LOW_REFRESH_BACKFILL_INTERVAL_MS = 3_600_000L
+
+ /** Pure baseline-cadence decision: low refresh swaps the 15-min BASE for the hourly one; every other
+ * lever composes on top with `max`, so a lever can only make the cadence quieter, never restore a
+ * faster one the user asked to slow down. Twin of Swift `BLEManager.baseBackfillInterval`. */
+ internal fun baseBackfillIntervalMs(lowRefresh: Boolean): Long =
+ if (lowRefresh) LOW_REFRESH_BACKFILL_INTERVAL_MS else BACKFILL_INTERVAL_MS
/** How far back the inactivity check reads gravity on each offload completion (4 h comfortably
* spans the threshold + re-nudge cadence and a separating Active break for bout continuity). */
private const val INACTIVITY_LOOKBACK_S = 4 * 3600L
@@ -1882,12 +1897,24 @@ class WhoopBleClient(
* OFF, so this ships dormant. The Settings picker offers 10/15/20/25/30. */
@Volatile private var lowBatteryOffloadPct: Int = 0
+ /** User-elected hourly background-sync cadence (Settings -> Power saving -> "Low refresh"). Default
+ * off. Like [lowBatteryOffloadPct] it takes effect on the NEXT re-arm. */
+ @Volatile private var lowRefreshMode: Boolean = false
+
/** Opt into the low-battery offload-cadence stretch (#477). Applies on the NEXT re-arm; a live sync
* in flight is never interrupted. */
fun setLowBatteryOffloadThrottle(thresholdPct: Int) {
lowBatteryOffloadPct = thresholdPct
}
+ /** Settings sub-option of Power saving: the user-elected hourly background cadence. Applies on the
+ * next re-arm like the battery lever above; a sync already in flight is never interrupted. Twin of
+ * Swift `BLEManager.setLowRefreshMode`. */
+ fun setLowRefreshMode(enabled: Boolean) {
+ if (lowRefreshMode == enabled) return
+ lowRefreshMode = enabled
+ }
+
/** #477: pause BACKGROUND continuous-HRV capture while the STRAP's battery is low (own toggle,
* DEFAULT OFF). A visible Live screen is unaffected — only the held-open background stream is
* released. Gated through [continuousCaptureWantsNow]. */
@@ -1911,20 +1938,23 @@ class WhoopBleClient(
* quietThreshold = 2) at a fixed 45-min floor that stacks with it. Twin of iOS
* `BLEManager.nextBackfillInterval`. Resets with [whoop5EmptyOffload] on disconnect. */
private fun nextBackfillDelayMs(): Long {
+ // Low refresh moves the BASE the other levers stretch from; each composes with `max`, so the
+ // cadence can only get quieter, never faster than the user asked for.
+ val base = baseBackfillIntervalMs(lowRefreshMode)
// #battery: known-empty-history 5/MG → stretch to the 45-min floor before any battery lever.
if (connectedFamily == DeviceFamily.WHOOP5) {
val stretched = whoop5EmptyHistoryBackfillIntervalMs(
- baseMs = BACKFILL_INTERVAL_MS,
- lowBatteryMs = LOW_BATTERY_BACKFILL_INTERVAL_MS,
+ baseMs = base,
+ lowBatteryMs = maxOf(base, LOW_BATTERY_BACKFILL_INTERVAL_MS),
historyEmpty = whoop5EmptyOffload.historyEmpty,
)
- if (stretched != BACKFILL_INTERVAL_MS) return stretched
+ if (stretched != base) return stretched
}
- if (lowBatteryOffloadPct <= 0) return BACKFILL_INTERVAL_MS // dormant: no battery read, unchanged cadence
+ if (lowBatteryOffloadPct <= 0) return base // dormant: no battery read, unchanged cadence
val (batteryPct, charging) = batteryPctAndCharging()
return offloadIntervalMsFor(
- baseMs = BACKFILL_INTERVAL_MS,
- lowBatteryMs = LOW_BATTERY_BACKFILL_INTERVAL_MS,
+ baseMs = base,
+ lowBatteryMs = maxOf(base, LOW_BATTERY_BACKFILL_INTERVAL_MS),
batteryPct = batteryPct,
charging = charging,
thresholdPct = lowBatteryOffloadPct,
diff --git a/android/app/src/main/java/com/noop/ui/AppViewModel.kt b/android/app/src/main/java/com/noop/ui/AppViewModel.kt
index a1f55f2b87..0c1625ef6a 100644
--- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt
+++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt
@@ -1154,6 +1154,9 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
// independent of the Power-saving master, and its own toggle so a field report can tell the two
// apart (they have opposite battery profiles). No-op unless on.
ble.setFastLinkPhy(NoopPrefs.fastLinkPhy(appContext))
+ // Low refresh is a sub-option too: only in effect while the master is on. Unlike the levers
+ // around it, it is NOT battery-gated once chosen — it moves the BASE cadence to hourly.
+ ble.setLowRefreshMode(on && NoopPrefs.lowRefresh(appContext))
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.
@@ -1175,6 +1178,12 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
applyPowerSaving()
}
+ /** Flip "Low refresh" (Settings). Persists + applies immediately. */
+ fun setLowRefresh(enabled: Boolean) {
+ NoopPrefs.setLowRefresh(appContext, enabled)
+ applyPowerSaving()
+ }
+
/** Flip "Pause HRV capture in Battery Saver" (Settings). Persists + applies immediately. */
fun setPauseHrvOnPowerSave(enabled: Boolean) {
NoopPrefs.setPauseHrvOnPowerSave(appContext, enabled)
diff --git a/android/app/src/main/java/com/noop/ui/MainActivity.kt b/android/app/src/main/java/com/noop/ui/MainActivity.kt
index fb629776c5..28919e5b76 100644
--- a/android/app/src/main/java/com/noop/ui/MainActivity.kt
+++ b/android/app/src/main/java/com/noop/ui/MainActivity.kt
@@ -250,6 +250,7 @@ object NoopPrefs {
* Benign — the strap banks to flash meanwhile, so sync just batches; no data loss, no link risk.
* Default OFF. Drives [com.noop.ble.WhoopBleClient.setLowBatteryOffloadThrottle] via [AppViewModel]. */
const val KEY_POWER_SAVING = "noop.powerSaving"
+ const val KEY_LOW_REFRESH = "low_refresh"
/** Battery-% threshold for [KEY_POWER_SAVING] (10/15/20/25/30). Default 20. */
const val KEY_POWER_SAVING_BATTERY_PCT = "noop.powerSavingBatteryPct"
/** "Pause HRV capture when the strap is low" (#477): when on, NOOP releases the held-open background
@@ -281,6 +282,14 @@ object NoopPrefs {
of(context).edit().putBoolean(KEY_POWER_SAVING, enabled).apply()
}
+ /** "Low refresh": sub-option of Power saving. Hourly background sync at ANY strap charge. Default off. */
+ fun lowRefresh(context: Context): Boolean =
+ of(context).getBoolean(KEY_LOW_REFRESH, false)
+
+ fun setLowRefresh(context: Context, enabled: Boolean) {
+ of(context).edit().putBoolean(KEY_LOW_REFRESH, enabled).apply()
+ }
+
/** Battery-% threshold for power saving (default 20). */
fun powerSavingBatteryPct(context: Context): Int =
of(context).getInt(KEY_POWER_SAVING_BATTERY_PCT, 20)
diff --git a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt
index 953e5fe300..82381d25ce 100644
--- a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt
+++ b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt
@@ -612,6 +612,7 @@ fun SettingsScreen(
// #477 Power saving: battery-adaptive strap-sync cadence + optional HRV-capture pause. Local mirrors.
var powerSaving by remember { mutableStateOf(NoopPrefs.powerSaving(context)) }
var powerSavingBatteryPct by remember { mutableStateOf(NoopPrefs.powerSavingBatteryPct(context)) }
+ var lowRefresh by remember { mutableStateOf(NoopPrefs.lowRefresh(context)) }
var pauseHrvOnPowerSave by remember { mutableStateOf(NoopPrefs.pauseHrvOnPowerSave(context)) }
// --- v5 Health & wellness toggle group. All SharedPreferences-backed (not reactive), so each Switch
@@ -2230,6 +2231,36 @@ fun SettingsScreen(
),
)
}
+ SettingsRowDivider()
+ // Low refresh: a sub-option that applies at ANY charge, not just below the threshold.
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(stringResource(R.string.power_saving_low_refresh), style = NoopType.subhead, color = Palette.textPrimary)
+ Text(
+ stringResource(R.string.power_saving_low_refresh_desc),
+ style = NoopType.footnote,
+ color = Palette.textTertiary,
+ )
+ }
+ Switch(
+ checked = lowRefresh,
+ onCheckedChange = {
+ lowRefresh = it
+ vm.setLowRefresh(it)
+ },
+ colors = SwitchDefaults.colors(
+ checkedThumbColor = Palette.surfaceBase,
+ checkedTrackColor = Palette.accent,
+ uncheckedThumbColor = Palette.textSecondary,
+ uncheckedTrackColor = Palette.surfaceInset,
+ uncheckedBorderColor = Palette.hairline,
+ ),
+ )
+ }
}
}
diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml
index 2423d3c9ae..1f45bb1d05 100644
--- a/android/app/src/main/res/values-de/strings.xml
+++ b/android/app/src/main/res/values-de/strings.xml
@@ -2143,4 +2143,6 @@
Entfernt diesen aufgezeichneten Schlaf und berechnet den Tag ohne ihn neu. NOOP erkennt in diesem Zeitfenster keinen Schlaf mehr. Du kannst dies für ein paar Sekunden rückgängig machen.
Optional · VO₂max entsteht aus ~4 Nächten Herzfrequenz; ein Taillenumfang macht ihn genauer. Das Fitness-Alter selbst braucht ihn nicht. Miss um deine Körpermitte, auf Höhe des Bauchnabels.
Dein VO₂max nutzt deinen Taillenumfang für eine genauere Schätzung
+ Seltener aktualisieren
+ Synchronisiert im Hintergrund stündlich statt alle 15 Minuten, unabhängig vom Ladestand — weniger Verbindungsaufbauten sind die größte Ersparnis bei einer WHOOP 4.0. Es geht nichts verloren: Das Band speichert alles und übergibt es in größeren Paketen. Manuelles Synchronisieren läuft weiterhin sofort, die Live-Herzfrequenz bleibt unberührt.
diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml
index 68ed704c58..7fe0410561 100644
--- a/android/app/src/main/res/values-es/strings.xml
+++ b/android/app/src/main/res/values-es/strings.xml
@@ -2129,4 +2129,6 @@
Elimina este sueño registrado y recalcula el día sin él. NOOP no volverá a detectar sueño en esta ventana. Puedes deshacerlo durante unos segundos.
Opcional · El VO₂max se construye a partir de ~4 noches de frecuencia cardíaca; la cintura lo hace más preciso. La Edad de forma física no la necesita. Mide alrededor de tu cintura, a la altura del ombligo.
Tu VO₂max usa tu cintura para una estimación más precisa
+ Actualización lenta
+ Sincroniza en segundo plano cada hora en vez de cada 15 minutos, sea cual sea la carga de la banda: menos reconexiones es el mayor ahorro en una WHOOP 4.0. No se pierde nada: la banda lo guarda todo y lo entrega en lotes más grandes. Sincronizar manualmente sigue siendo inmediato y la frecuencia cardíaca en vivo no cambia.
diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml
index b86157b57f..ed71ebd767 100644
--- a/android/app/src/main/res/values-fr/strings.xml
+++ b/android/app/src/main/res/values-fr/strings.xml
@@ -2128,4 +2128,6 @@
Supprime ce sommeil enregistré et recalcule la journée sans lui. NOOP ne redétectera pas de sommeil dans cette fenêtre. Vous pouvez annuler pendant quelques secondes.
Facultatif · La VO₂max se construit à partir d’environ 4 nuits de fréquence cardiaque ; le tour de taille la rend plus précise. L’Âge de forme n’en a pas besoin. Mesurez autour de votre taille, au niveau du nombril.
Votre VO₂max utilise votre tour de taille pour une estimation plus précise
+ Rafraîchissement réduit
+ Synchronise en arrière-plan toutes les heures au lieu de toutes les 15 minutes, quelle que soit la charge du bracelet : moins de reconnexions, c\'est la plus grosse économie sur une WHOOP 4.0. Rien n\'est perdu : le bracelet enregistre tout et le transmet par lots plus importants. La synchro manuelle reste immédiate et la fréquence cardiaque en direct n\'est pas affectée.
diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml
index b55dfc1ee2..49e676ec29 100644
--- a/android/app/src/main/res/values-pl/strings.xml
+++ b/android/app/src/main/res/values-pl/strings.xml
@@ -2122,4 +2122,6 @@
Usuwa ten zarejestrowany sen i przelicza dzień bez niego. NOOP nie wykryje ponownie snu w tym oknie. Możesz cofnąć przez kilka sekund.
Opcjonalnie · VO₂max powstaje z ~4 nocy pomiaru tętna; obwód talii zwiększa dokładność. Wiek sprawnościowy go nie potrzebuje. Zmierz w pasie, na wysokości pępka.
Twój VO₂max korzysta z obwodu talii, aby oszacowanie było dokładniejsze
+ Rzadsze odświeżanie
+ Synchronizuje w tle co godzinę zamiast co 15 minut, niezależnie od poziomu naładowania opaski — mniej ponownych połączeń to największa oszczędność w WHOOP 4.0. Nic nie ginie: opaska zapisuje wszystko i przekazuje w większych paczkach. Ręczna synchronizacja nadal działa od razu, a tętno na żywo pozostaje bez zmian.
diff --git a/android/app/src/main/res/values-pt-rPT/strings.xml b/android/app/src/main/res/values-pt-rPT/strings.xml
index 0a6d7d6e02..85048e13dc 100644
--- a/android/app/src/main/res/values-pt-rPT/strings.xml
+++ b/android/app/src/main/res/values-pt-rPT/strings.xml
@@ -2122,4 +2122,6 @@
Remove este sono registado e recalcula o dia sem ele. O NOOP não vai voltar a detetar sono nesta janela. Podes anular durante alguns segundos.
Opcional · O VO₂max é construído a partir de ~4 noites de frequência cardíaca; a cintura torna-o mais preciso. A Idade de forma física não precisa dela. Mede à volta da tua cintura, à altura do umbigo.
O teu VO₂max usa a tua cintura para uma estimativa mais precisa
+ Atualização reduzida
+ Sincroniza em segundo plano a cada hora em vez de a cada 15 minutos, seja qual for a carga da pulseira — menos religações é a maior poupança numa WHOOP 4.0. Nada se perde: a pulseira guarda tudo e entrega em lotes maiores. A sincronização manual continua imediata e a frequência cardíaca em direto não é afetada.
diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml
index 64ba40772c..4d00a9a9dc 100644
--- a/android/app/src/main/res/values-zh/strings.xml
+++ b/android/app/src/main/res/values-zh/strings.xml
@@ -2048,4 +2048,6 @@
移除此次已记录的睡眠并重新计算当天数据。NOOP 不会在此时间段内重新检测睡眠。之后几秒内可以撤销。
可选 · VO₂max 由约 4 晚的心率数据构建;腰围可让它更准确。健康年龄本身并不需要它。请在肚脐高度绕腰一周测量。
你的 VO₂max 使用腰围来获得更准确的估算
+ 低频刷新
+ 无论手环电量如何,后台同步改为每小时一次而非每 15 分钟一次——减少重新连接是 WHOOP 4.0 上最大的省电项。不会丢失任何数据:手环会记录全部内容,并以更大的批次交付。手动同步仍会立即运行,实时心率不受影响。
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index cbb9a1b880..afef7a999f 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -2155,4 +2155,6 @@
Removes this recorded sleep and recomputes the day without it. NOOP won\'t re-detect sleep in this window. You can undo for a few seconds after.
Optional · VO₂max builds from ~4 nights of heart rate; a waist makes it more accurate. The Fitness Age itself doesn’t need it. Measure around your middle, at the navel.
Your VO₂max uses your waist for a more accurate estimate
+ Low refresh
+ Sync in the background every hour instead of every 15 minutes, whatever the strap\'s charge — fewer reconnections is the biggest saving on a WHOOP 4.0. Nothing is lost: the strap banks everything and hands it over in larger batches. Pull to sync still runs straight away, and live heart rate is untouched.
diff --git a/android/app/src/test/java/com/noop/ble/LowRefreshCadenceTest.kt b/android/app/src/test/java/com/noop/ble/LowRefreshCadenceTest.kt
new file mode 100644
index 0000000000..58c1697c52
--- /dev/null
+++ b/android/app/src/test/java/com/noop/ble/LowRefreshCadenceTest.kt
@@ -0,0 +1,84 @@
+package com.noop.ble
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/**
+ * Kotlin twin of `LowRefreshCadenceTests.swift`. "Low refresh" (Settings -> Power saving sub-option)
+ * moves the BASE periodic-offload cadence from 15 min to 60 min. The invariant that matters is
+ * COMPOSITION: every other lever stretches FROM that base with `max`, so no lever can hand back a faster
+ * cadence than the user asked for, and a lever that would have stretched a 15-min base becomes a no-op
+ * once the base is already longer.
+ *
+ * Cadence only, by design: low refresh does NOT touch the keep-alive (that tick re-arms realtime and
+ * evaluates the stall fuse) and does NOT release continuous HRV capture (that is the separate
+ * `setPauseCaptureOnPowerSave` lever). Those are the two places a quieter radio would cost real data
+ * rather than merely delaying it.
+ */
+class LowRefreshCadenceTest {
+
+ @Test
+ fun baseIsFifteenMinutesWhenOff() {
+ assertEquals(900_000L, WhoopBleClient.baseBackfillIntervalMs(lowRefresh = false))
+ }
+
+ @Test
+ fun baseIsHourlyWhenOn() {
+ assertEquals(
+ WhoopBleClient.LOW_REFRESH_BACKFILL_INTERVAL_MS,
+ WhoopBleClient.baseBackfillIntervalMs(lowRefresh = true),
+ )
+ assertEquals(3_600_000L, WhoopBleClient.LOW_REFRESH_BACKFILL_INTERVAL_MS)
+ }
+
+ /** The low-battery lever (45 min) is SHORTER than low refresh (60 min): composed with `max` it must
+ * leave the hourly cadence alone rather than speeding it back up. */
+ @Test
+ fun lowBatteryLeverNeverShortensLowRefresh() {
+ val base = WhoopBleClient.baseBackfillIntervalMs(lowRefresh = true)
+ val composed = WhoopBleClient.offloadIntervalMsFor(
+ baseMs = base,
+ lowBatteryMs = maxOf(base, 2_700_000L),
+ batteryPct = 10,
+ charging = false,
+ thresholdPct = 20, // lever fully engaged
+ )
+ assertEquals(WhoopBleClient.LOW_REFRESH_BACKFILL_INTERVAL_MS, composed)
+ }
+
+ /** With low refresh off the same engaged lever still stretches 15 -> 45 min: inert for everyone who
+ * does not turn it on. */
+ @Test
+ fun defaultBehaviourIsUnchangedWhenOff() {
+ val base = WhoopBleClient.baseBackfillIntervalMs(lowRefresh = false)
+ assertEquals(
+ 2_700_000L,
+ WhoopBleClient.offloadIntervalMsFor(
+ baseMs = base, lowBatteryMs = maxOf(base, 2_700_000L),
+ batteryPct = 10, charging = false, thresholdPct = 20,
+ ),
+ )
+ // …and an idle/charged strap keeps the plain 15-min cadence.
+ assertEquals(
+ 900_000L,
+ WhoopBleClient.offloadIntervalMsFor(
+ baseMs = base, lowBatteryMs = maxOf(base, 2_700_000L),
+ batteryPct = 90, charging = false, thresholdPct = 20,
+ ),
+ )
+ }
+
+ /** The 5/MG empty-history stretch (45 min) composes the same way. */
+ @Test
+ fun whoop5EmptyHistoryStretchNeverShortensLowRefresh() {
+ val base = WhoopBleClient.baseBackfillIntervalMs(lowRefresh = true)
+ assertEquals(
+ WhoopBleClient.LOW_REFRESH_BACKFILL_INTERVAL_MS,
+ WhoopBleClient.whoop5EmptyHistoryBackfillIntervalMs(
+ baseMs = base,
+ lowBatteryMs = maxOf(base, 2_700_000L),
+ historyEmpty = true,
+ ),
+ )
+ }
+}