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, + ), + ) + } +}