Skip to content

l10n(de): localize the Trends / Explore / Deep Timeline screens (Android) - #451

Merged
ryanbr merged 1 commit into
ryanbr:mainfrom
digitalerdude:fix/i18n-process-448
Jul 15, 2026
Merged

l10n(de): localize the Trends / Explore / Deep Timeline screens (Android)#451
ryanbr merged 1 commit into
ryanbr:mainfrom
digitalerdude:fix/i18n-process-448

Conversation

@digitalerdude

Copy link
Copy Markdown

What

The three Trends-tab screens on Android were built with hardcoded English literals throughout — title / subtitle / labels / empty-states passed straight to a Compose Text(...), never wired to stringResource() at all. So on a German device "Charge" / "Effort" / "Reset" (and their neighbours) stayed English regardless of catalog state, because Compose has no SwiftUI-style auto-extraction: a raw string literal is rendered verbatim. This is the Android side of what's been reported repeatedly — #326, #342, and #448.

This routes every literal directly passed to a Compose call in TrendsScreen.kt, TrendsExploreScreen.kt and FullDayChartScreen.kt through R.string.*, adds the English base keys plus their German translations, and mirrors the German wording to the existing iOS/macOS Localizable.xcstrings entries where one exists (Ladung / Anstrengung / Ruhe / Zurücksetzen …) so the two platforms read the same.

Deliberately out of scope

The per-metric names held as plain String fields on TrendsExploreScreen's MetricSpec data class and TimelineMetric's titles (HRV, Sleep Efficiency, Blood Oxygen, Respiratory Rate) are not literals at a call site — localizing them needs a String -> @StringRes Int type change, a larger refactor than this pass. They're called out in a code comment and still flagged by the audit tool.

Verification

  • ./gradlew compileFullDebugKotlin — passes
  • ./gradlew testFullDebugUnitTest — passes
  • both strings.xml validate (xmllint); no German key gaps introduced

Only UI-string wiring; no analytics, storage, or BLE paths touched.

…oid)

The three Trends-tab screens were built with hardcoded English literals throughout
(title/subtitle/labels/empty-states passed straight to Compose Text), never wired to
stringResource() at all — so "Charge" / "Effort" / "Reset" and their neighbours stayed
English on a German device regardless of catalog state (Compose has no SwiftUI-style
auto-extraction; a raw literal is rendered verbatim). Reported repeatedly (ryanbr#326, ryanbr#342, ryanbr#448).

Routes every literal directly passed to a Compose call in TrendsScreen.kt,
TrendsExploreScreen.kt and FullDayChartScreen.kt through R.string.*, adds the English
base keys + their German translations, and mirrors the wording to the existing iOS/macOS
Localizable.xcstrings entries where one exists (Ladung / Anstrengung / Ruhe / Zurücksetzen …)
so the two platforms read the same.

Out of scope (tracked separately, still flagged by Tools/i18n_audit.py): the per-metric
names held as plain String fields on TrendsExploreScreen's MetricSpec data class and
TimelineMetric's titles (HRV, Sleep Efficiency, Blood Oxygen, Respiratory Rate) — those
aren't literals at a call site, so localizing them needs a String -> @stringres Int type
change, a larger refactor than this pass.

Verified: ./gradlew compileFullDebugKotlin + testFullDebugUnitTest pass; both strings.xml
validate; no de key gaps introduced.
@ryanbr
ryanbr marked this pull request as ready for review July 15, 2026 04:15
@ryanbr
ryanbr merged commit 9bd6c49 into ryanbr:main Jul 15, 2026
ryanbr added a commit that referenced this pull request Jul 15, 2026
Rolling testing build off main: #452 i18n gate, #449 calibration count,
#412 carried-Charge source label, #451 Trends l10n, #476 IMU decode-log.
Build numbers bumped (iOS 185→186, Android 274→275); no app-version change.
ryanbr added a commit that referenced this pull request Jul 15, 2026
Rolling testing build off main: #452 i18n gate, #449 calibration count,
#412 carried-Charge source label, #451 Trends l10n, #476 IMU decode-log.
Staging 8.8.0; build numbers 195/284 -> 196/285.
ryanbr added a commit that referenced this pull request Jul 15, 2026
Wire the 7 new Settings strings to R.string.* + German translations, so the #452
i18n gate passes (it correctly failed the new hardcoded literals). HRV=HFV per
the existing #451 vocabulary; %1$d%% readout keeps the format specifier.
ryanbr added a commit that referenced this pull request Jul 15, 2026
…ce, dormant default-off (#477) (#478)

* feat(ble): GATT connection-priority management, dormant default-off (#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.

* fix(ble): connection-priority hint must not trigger link teardown (#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 (#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 (#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 #478 battery
snapshot; complements the connection-priority lever, lower-risk (no drop risk).

* perf(ble): skip the battery read in safe-half-only mode (#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 (#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 (#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.

* feat(ui): Settings — Power saving section (battery % + HRV pause) (#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 (#478). Compiles
(:app:compileFullDebugKotlin).

* feat(ui): use a slider for the power-saving battery threshold (#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 (#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 (#477)

Wire the 7 new Settings strings to R.string.* + German translations, so the #452
i18n gate passes (it correctly failed the new hardcoded literals). HRV=HFV per
the existing #451 vocabulary; %1$d%% readout keeps the format specifier.
ryanbr added a commit that referenced this pull request Jul 15, 2026
Add the 5 new SettingsView strings to the Strand catalog + German (Stromsparmodus
for Low Power Mode, HFV per #451); make the %% readout Text(verbatim:) so it isn't
translated and isn't audit-flagged. Passes the #452 i18n gate (caught the new
un-extracted Swift literals, same as the Android side).
ryanbr added a commit that referenced this pull request Jul 15, 2026
…#477) (#479)

* feat(ble,ui): iOS parity for the battery-adaptive power-saving levers (#477)

Mirror the two benign Android levers into the Swift app:
- Offload-cadence stretch (15→45 min) — BLEManager offload timer is now one-shot,
  re-armed each tick with a battery-adaptive interval (pure offloadInterval()
  twin of Android offloadIntervalMsFor). Gated on battery-% OR Low Power Mode
  while discharging; iOS reads UIDevice battery, macOS falls back to (100,false).
- Continuous-HRV pause under Low Power Mode — gate in continuousCaptureWantsNow(),
  rides the existing reconcile/keep-alive re-derivation (no new receiver).
- Settings → Power saving section (master toggle + 10–30%% slider + HRV pause
  sub-option, on by default), @AppStorage in PuffinExperiment, applied at launch
  and each bond via AppModel.applyPowerSaving().

Connection-priority idle throttle stays Android-only (no CoreBluetooth
equivalent). All benign, default off. App-target Swift — validating via app-build.

* l10n(de): localize the iOS Power saving strings (#477)

Add the 5 new SettingsView strings to the Strand catalog + German (Stromsparmodus
for Low Power Mode, HFV per #451); make the %% readout Text(verbatim:) so it isn't
translated and isn't audit-flagged. Passes the #452 i18n gate (caught the new
un-extracted Swift literals, same as the Android side).

* feat(ble,ui): make the HRV-capture pause battery-%-aware (iOS) (#477)

Match the Android change: setPauseCaptureOnPowerSave takes the threshold and gates
on the shared lowPowerThrottleActive (battery ≤ % OR Low Power Mode), so the %
slider governs all three levers uniformly. Settings copy + de catalog updated.
App-target Swift — validating via app-build.
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…oid) (ryanbr#451)

The three Trends-tab screens were built with hardcoded English literals throughout
(title/subtitle/labels/empty-states passed straight to Compose Text), never wired to
stringResource() at all — so "Charge" / "Effort" / "Reset" and their neighbours stayed
English on a German device regardless of catalog state (Compose has no SwiftUI-style
auto-extraction; a raw literal is rendered verbatim). Reported repeatedly (ryanbr#326, ryanbr#342, ryanbr#448).

Routes every literal directly passed to a Compose call in TrendsScreen.kt,
TrendsExploreScreen.kt and FullDayChartScreen.kt through R.string.*, adds the English
base keys + their German translations, and mirrors the wording to the existing iOS/macOS
Localizable.xcstrings entries where one exists (Ladung / Anstrengung / Ruhe / Zurücksetzen …)
so the two platforms read the same.

Out of scope (tracked separately, still flagged by Tools/i18n_audit.py): the per-metric
names held as plain String fields on TrendsExploreScreen's MetricSpec data class and
TimelineMetric's titles (HRV, Sleep Efficiency, Blood Oxygen, Respiratory Rate) — those
aren't literals at a call site, so localizing them needs a String -> @stringres Int type
change, a larger refactor than this pass.

Verified: ./gradlew compileFullDebugKotlin + testFullDebugUnitTest pass; both strings.xml
validate; no de key gaps introduced.
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…ce, dormant default-off (ryanbr#477) (ryanbr#478)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat(ble,ui): iOS parity for the battery-adaptive power-saving levers (ryanbr#477)

Mirror the two benign Android levers into the Swift app:
- Offload-cadence stretch (15→45 min) — BLEManager offload timer is now one-shot,
  re-armed each tick with a battery-adaptive interval (pure offloadInterval()
  twin of Android offloadIntervalMsFor). Gated on battery-% OR Low Power Mode
  while discharging; iOS reads UIDevice battery, macOS falls back to (100,false).
- Continuous-HRV pause under Low Power Mode — gate in continuousCaptureWantsNow(),
  rides the existing reconcile/keep-alive re-derivation (no new receiver).
- Settings → Power saving section (master toggle + 10–30%% slider + HRV pause
  sub-option, on by default), @AppStorage in PuffinExperiment, applied at launch
  and each bond via AppModel.applyPowerSaving().

Connection-priority idle throttle stays Android-only (no CoreBluetooth
equivalent). All benign, default off. App-target Swift — validating via app-build.

* l10n(de): localize the iOS Power saving strings (ryanbr#477)

Add the 5 new SettingsView strings to the Strand catalog + German (Stromsparmodus
for Low Power Mode, HFV per ryanbr#451); make the %% readout Text(verbatim:) so it isn't
translated and isn't audit-flagged. Passes the ryanbr#452 i18n gate (caught the new
un-extracted Swift literals, same as the Android side).

* feat(ble,ui): make the HRV-capture pause battery-%-aware (iOS) (ryanbr#477)

Match the Android change: setPauseCaptureOnPowerSave takes the threshold and gates
on the shared lowPowerThrottleActive (battery ≤ % OR Low Power Mode), so the %
slider governs all three levers uniformly. Settings copy + de catalog updated.
App-target Swift — validating via app-build.
ryanbr added a commit that referenced this pull request Aug 7, 2026
…t just its drivers

The rolling capture already carries the drain DRIVERS (offload cadence,
#364/#451 re-kicks, PHY churn, connection duty cycle, the #836 re-score), but
the only battery figures in the log were the STRAP's SoC and a one-time phone
charging snapshot in the header — so you couldn't read a phone drain RATE off
it.

Emit a `[phonebattery] level=NN% charging=y/n temp=..C t=..s` line on the same
~60s keep-alive cadence the strap battery already polls at, from a one-shot
sticky ACTION_BATTERY_CHANGED read (no receiver lifecycle). Now a captured log
carries the phone-battery curve on the SAME timeline as the offload/connection
activity — you can see the phone drop N% across a given offload burst. Cheap,
unconditional (low volume, useful in any shared log), PII-free.
ryanbr added a commit that referenced this pull request Aug 7, 2026
…file (#1121)

* test centre: adb-like detailed capture — rolling on-device strap-log file

The one-shot "Share strap log" only holds the in-memory ring (5000 lines ≈
~50 min), and the battery-poll / connection-frame volume burns that fast — so
a long-running issue (battery drain, an overnight offload) scrolls out of the
window before it can be shared. adb logcat has no such cap but needs a
computer.

Add an opt-in "Detailed capture to file" toggle (Test Centre → Diagnostic
Tools) that mirrors every log() line into a rolling on-device file
(filesDir/noop-capture-log.txt, ≤8 MB with one previous generation kept as
.1) — hours-to-a-full-day of coverage, no computer. "Share captured log"
concatenates both generations into one .txt via the existing FileProvider
share. Lines are the SAME PII-scrubbed text log() already stores in the ring,
so nothing extra leaks; the file never leaves the phone unless shared.

Reuses the rolling-file pattern the 5/MG capture already ships
(open-append-rotate-at-cap). Capture survives the process being killed:
AppViewModel re-arms it from the persisted pref on launch — important because
this phone class is not battery-exempt and Android kills the background BLE
overnight, the very window a battery capture needs to span. File IO is under
its own lock, flushes per line so a kill keeps the tail, and lives inside
log()'s existing no-throw guard so a file error can never reach the
connection path.

Android-only diagnostic tooling (no analytics/stored-data touched), like the
existing 5/MG capture — no Swift twin. Off by default.

* capture: sample the PHONE battery so the log covers battery drain, not just its drivers

The rolling capture already carries the drain DRIVERS (offload cadence,
#364/#451 re-kicks, PHY churn, connection duty cycle, the #836 re-score), but
the only battery figures in the log were the STRAP's SoC and a one-time phone
charging snapshot in the header — so you couldn't read a phone drain RATE off
it.

Emit a `[phonebattery] level=NN% charging=y/n temp=..C t=..s` line on the same
~60s keep-alive cadence the strap battery already polls at, from a one-shot
sticky ACTION_BATTERY_CHANGED read (no receiver lifecycle). Now a captured log
carries the phone-battery curve on the SAME timeline as the offload/connection
activity — you can see the phone drop N% across a given offload burst. Cheap,
unconditional (low volume, useful in any shared log), PII-free.

* capture: optimise the hot path — buffered writes + capture-gated sampling

Two re-review optimisations:

1. Stop flushing the capture file PER LINE. A per-line flush is a write()
   syscall on the GATT binder thread for every log line — worst during an
   offload burst (hundreds of lines in seconds). Instead let the BufferedWriter
   coalesce (it auto-flushes when its 8 KB buffer fills) and flush explicitly on
   the ~30s keep-alive tick, which bounds how much of a sparse idle tail an
   abrupt kill can lose to one tick. This mirrors the existing 5/MG capture,
   which likewise flushes on a cadence (every 100 lines), not per line.

2. Gate phone-battery sampling (and the tick flush) on a capture actually being
   active — one volatile read, zero work for the ~all users who never enable
   detailed capture. Previously the phone-battery sticky-intent read + an extra
   log line ran every 60s for everyone while connected.

Net: when capture is OFF, the whole feature costs one volatile read on the log
path and one on the keep-alive tick. When ON, no per-line syscalls and battery
sampling only where it's needed.
ryanbr added a commit that referenced this pull request Aug 8, 2026
…ws (#1144) (#1145)

A real WHOOP 4.0 capture showed the offload auto-continue (#364/#451) firing 7 bursts
of 24 empty offloads (~168 in 27 min), each with a 2M-PHY round-trip — a battery/radio
storm. Root cause: guard 2a (strapNewest - frontier > 300s -> continue) returns true
regardless of whether the offload pulled any rows. When the strap advertises a newest
ahead of our frontier but the offload hands back chunkRows=0 (a phantom gap), the
frontier can't advance, the gap stays > 300s, and 2a re-fires to the full 24 cap. Guard
3 (lastTrimAdvanced) doesn't catch it — the trim u32 climbs on empty ENDs.

Fix (both platforms, shouldAutoContinue kept byte-for-behaviour): an EMPTY session
(rowsPersistedThisSession <= 0) never auto-continues, whatever the reported gap. Collapses
each 24-spin into a single empty offload + fall back to the 15-min floor. Healthy deep
backlogs persist real rows (245-251/chunk), so they're unaffected.

Tests: added the phantom-gap regression (gap holds, rows=0 -> stop) both platforms;
continue-expecting cases now pass rows>0 (a frontier can't advance with 0 rows).
Verified: Kotlin testFullDebugUnitTest green; Swift shouldAutoContinue via isolated harness.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants