From 444735c3a952a5d37aa00b239e29fc08063c0afd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20Sch=C3=A4dlich?= Date: Sun, 26 Jul 2026 12:58:01 +0200 Subject: [PATCH 1/2] Surface pagesBehind on the sync chip while backfilling (#689/#815 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #806 declined a percentage-progress sync indicator because the WHOOP protocol never reveals a total pending-record count. But #818/#821 confirmed GET_DATA_RANGE's pagesBehind field (write pointer minus read pointer against the ring capacity) decodes correctly and validated it against real captures on both WHOOP 4.0 and 5.0/MG (#815) — a real, bounded backlog measure, just not a percentage. It was already logged as a diagnostic but never stored. This threads the single GET_DATA_RANGE sample taken at connect into LiveState (pagesBehindAtConnect) and appends it as a "N pages behind" detail to the sync chip's existing chunk count while backfilling is true — no new BLE traffic, no re-polling mid-offload (see #377 on firmware pacing), no percentage math. nil renders identically to before this field existed. Swift: LiveState.pagesBehindAtConnect, banked in BLEManager's handleDataRangeResponse, surfaced via SyncChipState.syncing(chunks:pagesBehind:) in both SyncStatusChip and LiquidSyncChip, localized into all 8 shipped locales. Kotlin: LiveState.pagesBehindAtConnect (WhoopBleClient), SyncChipState.Syncing extended (TodayScoring), SyncStatusChip/ChipCapsule render the detail (TodayScreen), strings added across the 6 shipped locale files. Tests: SyncChipStateTests (iOS) + SyncChipStateTest (Kotlin) cover the new pagesBehind-present case; GattCrashSafetyTest asserts disconnect clears the sample so it can't outlive the link. --- Strand/BLE/BLEManager.swift | 4 + Strand/BLE/LiveState.swift | 15 +++ Strand/Liquid/LiquidTodayView.swift | 11 +- Strand/Resources/Localizable.xcstrings | 104 ++++++++++++++++++ Strand/Screens/TodayView.swift | 42 ++++++- StrandTests/SyncChipStateTests.swift | 12 +- .../main/java/com/noop/ble/WhoopBleClient.kt | 13 +++ .../src/main/java/com/noop/ui/TodayScoring.kt | 24 +++- .../src/main/java/com/noop/ui/TodayScreen.kt | 19 +++- .../app/src/main/res/values-de/strings.xml | 2 + .../app/src/main/res/values-es/strings.xml | 2 + .../app/src/main/res/values-fr/strings.xml | 2 + .../src/main/res/values-pt-rPT/strings.xml | 2 + .../app/src/main/res/values-zh/strings.xml | 2 + android/app/src/main/res/values/strings.xml | 2 + .../java/com/noop/ble/GattCrashSafetyTest.kt | 3 + .../java/com/noop/ui/SyncChipStateTest.kt | 13 ++- 17 files changed, 255 insertions(+), 17 deletions(-) diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index b9ab47f8d9..c40ff002e0 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -4216,6 +4216,10 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { // revision ever shifts these fields again, the rejection must be visible rather than silent. if let pages = DataRange.pagesBehind(from: frame, cmdOff: cmdOff) { log("Strap backlog pages behind: \(pages) (#689 — GET_DATA_RANGE ring backlog, diagnostic only)") + // #815: confirmed on both WHOOP 4.0 and 5.0/MG, so bank it unconditionally (unlike newest/oldest, + // which stay feedsSync-gated to WHOOP4 until a 5/MG capture confirms them too) — the sync chip's + // "N pages behind" detail reads this while `backfilling` is true. + state.setPagesBehindAtConnect(pages) } else { log("Strap backlog pages behind: not decodable from this frame (#689 — offsets may have moved; " + "the raw frame above is the input). Diagnostic only, sync is unaffected.") diff --git a/Strand/BLE/LiveState.swift b/Strand/BLE/LiveState.swift index 3a59880076..98defe007e 100644 --- a/Strand/BLE/LiveState.swift +++ b/Strand/BLE/LiveState.swift @@ -204,6 +204,20 @@ public final class LiveState: ObservableObject { /// window can't outlive the link. public func clearStrapRange() { strapRange = nil } + /// #689/#815: the strap's ring-buffer page backlog, sampled ONCE from the connect-time GET_DATA_RANGE + /// reply — never re-polled mid-offload (the link is already firmware-paced, ~10 records/s, #377). A + /// static "this many pages behind" figure, not a live percentage: the strap never reveals a total + /// pending-record count, only this bounded ring-buffer measure (write pointer − read pointer against a + /// 131072-page ring), confirmed against real captures on both WHOOP 4.0 and 5.0/MG. The sync chip + /// appends it to the chunk count while `backfilling` is true. nil before the first reply this session, + /// or if the frame didn't decode. Cleared on disconnect so a stale figure can't outlive the link. + @Published public private(set) var pagesBehindAtConnect: Int? + + /// Bank the connect-time GET_DATA_RANGE pages-behind sample (see `pagesBehindAtConnect`). + public func setPagesBehindAtConnect(_ pages: Int) { + pagesBehindAtConnect = pages + } + @Published public var lastFrameType: String? = nil @Published public var lastEvent: String? = nil /// #987: unix of the most recent strap frame FrameRouter routed. Deliberately NOT @Published - the @@ -492,6 +506,7 @@ public final class LiveState: ObservableObject { recentHrSamples.removeAll() // Sleep readout buffers must not outlive the link (Group E) recentGravitySamples.removeAll() clearStrapRange() // a stale clock-drift window must not outlive the link either + pagesBehindAtConnect = nil // a stale pages-behind sample must not outlive the link either lastFrameAtUnix = nil // #987: a stale "last frame" freshness must not outlive it either ouraWearState = nil // a stale worn/charging badge must not outlive the link either // Perf: flush the durable log tail on disconnect (mirroring is batched in `append`), so a completed diff --git a/Strand/Liquid/LiquidTodayView.swift b/Strand/Liquid/LiquidTodayView.swift index 149f27eada..13c472041e 100644 --- a/Strand/Liquid/LiquidTodayView.swift +++ b/Strand/Liquid/LiquidTodayView.swift @@ -1949,9 +1949,10 @@ private struct LiquidSyncChip: View { var body: some View { switch SyncChipState.resolve(live: live) { - case .syncing(let chunks): + case .syncing(let chunks, let pagesBehind): pill(system: "arrow.triangle.2.circlepath", text: "\(chunks)", - a11y: String(localized: "Syncing strap history, \(chunks) chunks")) + detail: SyncChipState.pagesBehindDetail(pagesBehind), + a11y: SyncChipState.syncingAccessibilityLabel(chunks: chunks, pagesBehind: pagesBehind)) case .synced(let agoText): pill(system: "checkmark", text: agoText, a11y: String(localized: "Strap history synced \(agoText) ago")) @@ -1963,10 +1964,14 @@ private struct LiquidSyncChip: View { } } - private func pill(system: String, text: String, a11y: String) -> some View { + private func pill(system: String, text: String, detail: String? = nil, a11y: String) -> some View { HStack(spacing: 4) { Image(systemName: system).font(.system(size: 11, weight: .bold)) Text(text).font(.system(size: 12, weight: .bold)) + // #689/#815: the pages-behind detail, when known — see `SyncChipState.pagesBehindDetail`. + if let detail { + Text(detail).font(.system(size: 11, weight: .semibold)).foregroundStyle(.white.opacity(0.7)) + } } .foregroundStyle(.white) .padding(.horizontal, 10) diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 3ce58af975..783d77a775 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -169628,6 +169628,110 @@ } } } + }, + "%lld pages behind": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld Seiten im Rückstand" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%lld páginas de retraso" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%lld pages de retard" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%lld pagine di ritardo" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "%lld páginas em atraso" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%lld страниц позади" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "落后 %lld 页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "落後 %lld 頁" + } + } + } + }, + "Syncing strap history, %lld chunks, %lld pages behind": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verlaufssynchronisierung des Straps läuft, %lld Chunks, %lld Seiten im Rückstand" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sincronizando el historial de la pulsera, %lld bloques, %lld páginas de retraso" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Synchronisation de l'historique du bracelet, %lld blocs, %lld pages de retard" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sincronizzazione della cronologia della fascia, %lld blocchi, %lld pagine di ritardo" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "A sincronizar o histórico da bracelete, %lld pedaços, %lld páginas em atraso" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Синхронизация истории браслета, %lld фрагментов, %lld страниц позади" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在同步手环历史记录,%lld 个数据块,落后 %lld 页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在同步手環歷史記錄,%lld 個資料塊,落後 %lld 頁" + } + } + } } }, "version": "1.0" diff --git a/Strand/Screens/TodayView.swift b/Strand/Screens/TodayView.swift index a5443e8446..4f12717e82 100644 --- a/Strand/Screens/TodayView.swift +++ b/Strand/Screens/TodayView.swift @@ -4467,14 +4467,20 @@ struct TodayDayScopedCache { /// yet) → `✓ live`. `.hidden` only on a true cold start (the building-scores note owns that case). Twin /// of Android `SyncStatusChip`. enum SyncChipState: Equatable { - case syncing(chunks: Int) + /// #689/#815 follow-up: `pagesBehind` is the strap's GET_DATA_RANGE ring backlog sampled once at + /// connect (`LiveState.pagesBehindAtConnect`) — nil until a reply has landed this session, or on a + /// device/frame where it didn't decode. Purely additive: nil renders identically to before this field + /// existed, so it defaults to the chunk-count-only chip. + case syncing(chunks: Int, pagesBehind: Int? = nil) case synced(agoText: String) case experimentalLive case hidden @MainActor static func resolve(live: LiveState) -> SyncChipState { - if live.backfilling { return .syncing(chunks: live.syncChunksThisSession) } + if live.backfilling { + return .syncing(chunks: live.syncChunksThisSession, pagesBehind: live.pagesBehindAtConnect) + } if let ts = live.lastSyncedAt { return .synced(agoText: shortAgo(ts)) } if live.historySyncExperimental { return .experimentalLive } return .hidden @@ -4492,6 +4498,24 @@ enum SyncChipState: Equatable { if hrs < 24 { return "\(hrs)h" } return "\(hrs / 24)d" } + + /// The small trailing fragment the syncing chip appends beside the chunk count when a pages-behind + /// sample is known — "494 pages behind". nil (no fragment) when the sample hasn't landed yet, which + /// is exactly today's chunk-count-only rendering. Shared by `SyncStatusChip` and `LiquidSyncChip` so + /// the two headers can't word this differently. + static func pagesBehindDetail(_ pagesBehind: Int?) -> String? { + pagesBehind.map { String(localized: "\($0) pages behind") } + } + + /// VoiceOver label for the syncing state. Extends the existing chunk-count sentence with the + /// pages-behind figure when known, so the detail reaches accessibility users the same way it reaches + /// sighted ones via `pagesBehindDetail`, rather than being a sighted-only visual fragment. + static func syncingAccessibilityLabel(chunks: Int, pagesBehind: Int?) -> String { + if let pagesBehind { + return String(localized: "Syncing strap history, \(chunks) chunks, \(pagesBehind) pages behind") + } + return String(localized: "Syncing strap history, \(chunks) chunks") + } } /// #245: a compact sync-status chip for the Today top bar, shown to EVERY user. The full-width @@ -4504,9 +4528,10 @@ struct SyncStatusChip: View { var body: some View { switch SyncChipState.resolve(live: live) { - case .syncing(let chunks): - chip(system: "arrow.triangle.2.circlepath", text: "\(chunks)", tint: StrandPalette.accent, - a11y: String(localized: "Syncing strap history, \(chunks) chunks")) + case .syncing(let chunks, let pagesBehind): + chip(system: "arrow.triangle.2.circlepath", text: "\(chunks)", + detail: SyncChipState.pagesBehindDetail(pagesBehind), tint: StrandPalette.accent, + a11y: SyncChipState.syncingAccessibilityLabel(chunks: chunks, pagesBehind: pagesBehind)) case .synced(let agoText): chip(system: "checkmark", text: agoText, tint: StrandPalette.textSecondary, a11y: String(localized: "Strap history synced \(agoText) ago")) @@ -4519,10 +4544,15 @@ struct SyncStatusChip: View { } } - private func chip(system: String, text: String, tint: Color, a11y: String) -> some View { + private func chip(system: String, text: String, detail: String? = nil, tint: Color, a11y: String) -> some View { HStack(spacing: 4) { Image(systemName: system).font(.system(size: 11, weight: .semibold)) Text(text).font(StrandFont.captionNumber) + // #689/#815: the pages-behind detail, when known. A trailing fragment rather than a redesign + // of this DRAFT chip — see `SyncChipState.pagesBehindDetail`. + if let detail { + Text(detail).font(StrandFont.caption).foregroundStyle(tint.opacity(0.7)) + } } .foregroundStyle(tint) .padding(.horizontal, 8) diff --git a/StrandTests/SyncChipStateTests.swift b/StrandTests/SyncChipStateTests.swift index feb7ddda9d..b745938b9a 100644 --- a/StrandTests/SyncChipStateTests.swift +++ b/StrandTests/SyncChipStateTests.swift @@ -12,7 +12,17 @@ final class SyncChipStateTests: XCTestCase { let live = LiveState() live.backfilling = true live.syncChunksThisSession = 7 - XCTAssertEqual(SyncChipState.resolve(live: live), .syncing(chunks: 7)) + XCTAssertEqual(SyncChipState.resolve(live: live), .syncing(chunks: 7, pagesBehind: nil)) + } + + /// #689/#815 follow-up: when a GET_DATA_RANGE sample has landed this session, it rides along on the + /// syncing state so the chip can append the "N pages behind" detail. + func testBackfilling_withPagesBehindSample_carriesItOnSyncingState() { + let live = LiveState() + live.backfilling = true + live.syncChunksThisSession = 7 + live.setPagesBehindAtConnect(494) + XCTAssertEqual(SyncChipState.resolve(live: live), .syncing(chunks: 7, pagesBehind: 494)) } func testLastSyncedAt_isSyncedWithAgeText() { 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 5e3d010e67..fedc1deba3 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -208,6 +208,14 @@ data class LiveState( * once empty offloads are SUSTAINED; cleared on connect or once the strap banks real records. Twin of * macOS LiveState.historySyncExperimental. */ val historySyncExperimental: Boolean = false, + /** #689/#815: the strap's ring-buffer page backlog, sampled ONCE from the connect-time GET_DATA_RANGE + * reply — never re-polled mid-offload (the link is already firmware-paced, ~10 records/s). A static + * "pages behind" figure, not a live percentage: the strap never reveals a total record count, only + * this bounded ring-buffer measure (write pointer − read pointer against a 131072-page ring), + * confirmed against real captures on both WHOOP 4.0 and 5.0/MG. The Today sync chip appends it to + * the chunk count while [backfilling] is true. null before the first reply this session, or if the + * frame didn't decode. Twin of macOS LiveState.pagesBehindAtConnect. */ + val pagesBehindAtConnect: Int? = null, ) { /** Set the fresh-packet [rr] AND append the valid intervals onto the bounded [rrRecent] rolling * buffer (oldest fall off first). Non-positive sentinels are dropped from the rolling buffer. @@ -973,6 +981,8 @@ class WhoopBleClient( // #580: the 5/MG "history experimental" note is per-link — a fresh connect re-derives it // from the next offload, so it must not outlive the dropped link. historySyncExperimental = false, + // A stale pages-behind sample must not outlive the dropped link either (#689/#815). + pagesBehindAtConnect = null, ) /** @@ -4484,6 +4494,9 @@ class WhoopBleClient( val pagesBehind = com.noop.protocol.DataRange.pagesBehind(frame, cmdOff) if (pagesBehind != null) { log("Strap backlog pages behind: $pagesBehind (#689 — GET_DATA_RANGE ring backlog, diagnostic only)") + // #815: confirmed on both WHOOP 4.0 and 5.0/MG, so bank it unconditionally — the + // Today sync chip's "N pages behind" detail reads this while backfilling is true. + _state.update { it.copy(pagesBehindAtConnect = pagesBehind) } } else { log( "Strap backlog pages behind: not decodable from this frame (#689 — offsets may " + diff --git a/android/app/src/main/java/com/noop/ui/TodayScoring.kt b/android/app/src/main/java/com/noop/ui/TodayScoring.kt index afd0a85da6..1c003d0bb8 100644 --- a/android/app/src/main/java/com/noop/ui/TodayScoring.kt +++ b/android/app/src/main/java/com/noop/ui/TodayScoring.kt @@ -381,7 +381,11 @@ internal fun recordingStateFor( * [Hidden] only on a true cold start (the building-scores note owns that case). Previously this * priority order lived inline inside the `@Composable`, where it could not be unit-tested. */ sealed class SyncChipState { - data class Syncing(val chunks: Int) : SyncChipState() + /** #689/#815 follow-up: [pagesBehind] is the strap's GET_DATA_RANGE ring backlog sampled once at + * connect (`LiveState.pagesBehindAtConnect`) — null until a reply has landed this session, or on a + * device/frame where it didn't decode. Purely additive: null renders identically to before this + * field existed, so it defaults to the chunk-count-only chip. Mirrors Swift `SyncChipState.syncing`. */ + data class Syncing(val chunks: Int, val pagesBehind: Int? = null) : SyncChipState() data class Synced(val agoText: String) : SyncChipState() object ExperimentalLive : SyncChipState() object Hidden : SyncChipState() @@ -394,8 +398,9 @@ sealed class SyncChipState { chunks: Int, lastSyncAtSec: Long?, historySyncExperimental: Boolean, + pagesBehind: Int? = null, ): SyncChipState = when { - backfilling -> Syncing(chunks) + backfilling -> Syncing(chunks, pagesBehind) lastSyncAtSec != null -> Synced(shortSyncAgo(lastSyncAtSec)) historySyncExperimental -> ExperimentalLive else -> Hidden @@ -416,6 +421,21 @@ internal fun shortSyncAgo(unixSec: Long): String { } } +/** #689/#815 follow-up: the small trailing fragment the syncing chip appends beside the chunk count when + * a pages-behind sample is known — "494 pages behind". null (no fragment) when the sample hasn't landed + * yet, which is exactly today's chunk-count-only rendering. Mirrors iOS `SyncChipState.pagesBehindDetail`. */ +internal fun pagesBehindDetail(pagesBehind: Int?): String? = + pagesBehind?.let { uiString(R.string.l10n_today_screen_sync_chip_pages_behind_29b5c548, it) } + +/** Accessibility/content-description label for the syncing state. Extends the existing chunk-count + * sentence with the pages-behind figure when known. Mirrors iOS `SyncChipState.syncingAccessibilityLabel`. */ +internal fun syncingAccessibilityLabel(chunks: Int, pagesBehind: Int?): String = + if (pagesBehind != null) { + uiString(R.string.l10n_today_screen_sync_chip_syncing_pages_desc_6c2a5ea9, chunks, pagesBehind) + } else { + uiString(R.string.l10n_today_screen_sync_chip_syncing_desc_bfc290e7, chunks) + } + /** Whether this night's sleep staging is low-confidence, using the core [ScoreConfidence] rule. */ internal fun restStageLowConfidence(d: DailyMetric?): Boolean { val asleepMin = d?.totalSleepMin ?: return false diff --git a/android/app/src/main/java/com/noop/ui/TodayScreen.kt b/android/app/src/main/java/com/noop/ui/TodayScreen.kt index 9e14e2c345..7ca4464590 100644 --- a/android/app/src/main/java/com/noop/ui/TodayScreen.kt +++ b/android/app/src/main/java/com/noop/ui/TodayScreen.kt @@ -1169,6 +1169,7 @@ fun TodayScreen( syncChunksThisSession = liveSnap.syncChunksThisSession, lastSyncAt = liveSnap.lastSyncAt, historySyncExperimental = liveSnap.historySyncExperimental, + pagesBehindAtConnect = liveSnap.pagesBehindAtConnect, onPickDay = { offset -> selectedDayOffset = offset }, onQuickActions = onQuickActions, onOpenSettings = onOpenSettings, @@ -2004,6 +2005,8 @@ private fun LiquidTodayHeader( syncChunksThisSession: Int = 0, lastSyncAt: Long? = null, historySyncExperimental: Boolean = false, + // #689/#815 follow-up: the connect-time GET_DATA_RANGE pages-behind sample, when known. + pagesBehindAtConnect: Int? = null, onPickDay: (Int) -> Unit, onQuickActions: () -> Unit, onOpenSettings: () -> Unit, @@ -2093,6 +2096,7 @@ private fun LiquidTodayHeader( SyncStatusChip( backfilling = backfilling, chunks = syncChunksThisSession, lastSyncAt = lastSyncAt, historySyncExperimental = historySyncExperimental, + pagesBehind = pagesBehindAtConnect, ) // (a) Profile avatar (the photo set in Settings, or the NOOP loop mark) → Settings. Mirrors iOS. Box( @@ -2131,11 +2135,13 @@ private fun SyncStatusChip( chunks: Int, lastSyncAt: Long?, historySyncExperimental: Boolean, + pagesBehind: Int? = null, ) { - when (val state = SyncChipState.resolve(backfilling, chunks, lastSyncAt, historySyncExperimental)) { + when (val state = SyncChipState.resolve(backfilling, chunks, lastSyncAt, historySyncExperimental, pagesBehind)) { is SyncChipState.Syncing -> ChipCapsule( Icons.Filled.Autorenew, "${state.chunks}", Palette.accent, - uiString(R.string.l10n_today_screen_sync_chip_syncing_desc_bfc290e7, state.chunks)) + syncingAccessibilityLabel(state.chunks, state.pagesBehind), + detail = pagesBehindDetail(state.pagesBehind)) is SyncChipState.Synced -> ChipCapsule( Icons.Filled.Check, state.agoText, Palette.textSecondary, uiString(R.string.l10n_today_screen_sync_chip_synced_desc_4d255944, state.agoText)) @@ -2147,9 +2153,11 @@ private fun SyncStatusChip( } } -/** The shared sync-chip capsule (icon + terse label). Twin of the iOS `SyncStatusChip.chip`. */ +/** The shared sync-chip capsule (icon + terse label). Twin of the iOS `SyncStatusChip.chip`. + * #689/#815: [detail] is the pages-behind fragment appended beside [text], when known — + * see [pagesBehindDetail]. */ @Composable -private fun ChipCapsule(icon: ImageVector, text: String, tint: Color, desc: String) { +private fun ChipCapsule(icon: ImageVector, text: String, tint: Color, desc: String, detail: String? = null) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -2160,6 +2168,9 @@ private fun ChipCapsule(icon: ImageVector, text: String, tint: Color, desc: Stri ) { Icon(icon, contentDescription = desc, tint = tint, modifier = Modifier.size(14.dp)) Text(text, style = NoopType.caption, color = tint) + if (detail != null) { + Text(detail, style = NoopType.caption, color = tint.copy(alpha = 0.7f)) + } } } diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index a6f1e67041..d0dd7f6ce6 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -1599,6 +1599,8 @@ jetzt Live Verlaufssynchronisierung des Straps läuft, %1$d Chunks + %1$d Seiten im Rückstand + Verlaufssynchronisierung des Straps läuft, %1$d Chunks, %2$d Seiten im Rückstand Verlauf des Straps vor %1$s synchronisiert Verbunden; die Verlaufssynchronisierung ist bei diesem Strap experimentell. %1$s%% Ladung diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 1806b129ed..d25c936417 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -1439,6 +1439,8 @@ ahora en vivo Sincronizando el historial de la pulsera, %1$d bloques + %1$d páginas de retraso + Sincronizando el historial de la pulsera, %1$d bloques, %2$d páginas de retraso Historial de la pulsera sincronizado hace %1$s Conectado; la sincronización del historial es experimental en esta pulsera. %1$s%% diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index e9245e1d6d..8e6a3bb2df 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -1439,6 +1439,8 @@ à l\'instant en direct Synchronisation de l\'historique du bracelet, %1$d blocs + %1$d pages de retard + Synchronisation de l\'historique du bracelet, %1$d blocs, %2$d pages de retard Historique du bracelet synchronisé il y a %1$s Connecté ; la synchronisation de l\'historique est expérimentale sur ce bracelet. Charge %1$s%% 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 4e77b8073e..b7284bf307 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -1580,6 +1580,8 @@ agora em direto A sincronizar o histórico da bracelete, %1$d pedaços + %1$d páginas em atraso + A sincronizar o histórico da bracelete, %1$d pedaços, %2$d páginas em atraso Histórico da bracelete sincronizado há %1$s Ligada; a sincronização do histórico é experimental nesta bracelete. %1$s%% Carga diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index 852bb310e1..80dfb20c10 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -1561,6 +1561,8 @@ 刚刚 实时 正在同步腕带历史记录,%1$d 个数据块 + 落后 %1$d 页 + 正在同步腕带历史记录,%1$d 个数据块,落后 %2$d 页 手环历史记录已在 %1$s 前同步 已连接;此手环的历史同步功能为实验性功能。 %1$s%% 充能(Charge) diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 14389cc086..45034385a0 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1608,6 +1608,8 @@ now live Syncing strap history, %1$d chunks + %1$d pages behind + Syncing strap history, %1$d chunks, %2$d pages behind Strap history synced %1$s ago Connected; strap history sync is experimental on this strap %1$s%% Charge diff --git a/android/app/src/test/java/com/noop/ble/GattCrashSafetyTest.kt b/android/app/src/test/java/com/noop/ble/GattCrashSafetyTest.kt index dd6e97a8e4..cd49c0f910 100644 --- a/android/app/src/test/java/com/noop/ble/GattCrashSafetyTest.kt +++ b/android/app/src/test/java/com/noop/ble/GattCrashSafetyTest.kt @@ -82,6 +82,7 @@ class GattCrashSafetyTest { historyLayoutVersion = 25, backfilling = true, syncChunksThisSession = 12, + pagesBehindAtConnect = 494, ) val after = WhoopBleClient.disconnectedLiveState(live) @@ -100,6 +101,8 @@ class GattCrashSafetyTest { // A stale charging flag must not outlive the link. assertNull(after.charging) assertNull(after.strapFirmware) + // A stale pages-behind sample must not outlive the dropped link either (#689/#815). + assertNull(after.pagesBehindAtConnect) assertNull(after.historyLayoutVersion) } diff --git a/android/app/src/test/java/com/noop/ui/SyncChipStateTest.kt b/android/app/src/test/java/com/noop/ui/SyncChipStateTest.kt index aef8fd9622..0c029abb6d 100644 --- a/android/app/src/test/java/com/noop/ui/SyncChipStateTest.kt +++ b/android/app/src/test/java/com/noop/ui/SyncChipStateTest.kt @@ -16,7 +16,18 @@ class SyncChipStateTest { val state = SyncChipState.resolve( backfilling = true, chunks = 7, lastSyncAtSec = null, historySyncExperimental = false, ) - assertEquals(SyncChipState.Syncing(7), state) + assertEquals(SyncChipState.Syncing(7, null), state) + } + + /** #689/#815 follow-up: when a GET_DATA_RANGE sample has landed this session, it rides along on the + * syncing state so the chip can append the "N pages behind" detail. */ + @Test + fun backfilling_withPagesBehindSample_carriesItOnSyncingState() { + val state = SyncChipState.resolve( + backfilling = true, chunks = 7, lastSyncAtSec = null, historySyncExperimental = false, + pagesBehind = 494, + ) + assertEquals(SyncChipState.Syncing(7, 494), state) } @Test From 035ace48afceeb8d6e1bf177bd963eb78ece2440 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20Sch=C3=A4dlich?= Date: Sun, 26 Jul 2026 13:19:42 +0200 Subject: [PATCH 2/2] Fix Kotlin compile errors found by compileFullDebugKotlin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DataRange.pagesBehind() returns Long on Kotlin (Int on Swift, a pre-existing platform asymmetry) — convert at the LiveState write so pagesBehindAtConnect stays Int, matching the Swift type. TodayScreen reads the header's LiveState through a derivedStateOf snapshot (TodayLiveSnapshot) that scopes recomposition to a minimal stable field set, not the raw LiveState — the new field needed adding there too, not just on LiveState itself. --- android/app/src/main/java/com/noop/ble/WhoopBleClient.kt | 2 +- android/app/src/main/java/com/noop/ui/TodayScreen.kt | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) 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 fedc1deba3..8eb6d225c6 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -4496,7 +4496,7 @@ class WhoopBleClient( log("Strap backlog pages behind: $pagesBehind (#689 — GET_DATA_RANGE ring backlog, diagnostic only)") // #815: confirmed on both WHOOP 4.0 and 5.0/MG, so bank it unconditionally — the // Today sync chip's "N pages behind" detail reads this while backfilling is true. - _state.update { it.copy(pagesBehindAtConnect = pagesBehind) } + _state.update { it.copy(pagesBehindAtConnect = pagesBehind.toInt()) } } else { log( "Strap backlog pages behind: not decodable from this frame (#689 — offsets may " + diff --git a/android/app/src/main/java/com/noop/ui/TodayScreen.kt b/android/app/src/main/java/com/noop/ui/TodayScreen.kt index 7ca4464590..2c93957c09 100644 --- a/android/app/src/main/java/com/noop/ui/TodayScreen.kt +++ b/android/app/src/main/java/com/noop/ui/TodayScreen.kt @@ -236,6 +236,9 @@ private data class TodayLiveSnapshot( val whoop5: Boolean, /** Charging hides the runtime estimate (no "X left" while topping up). Rare flips, snapshot-safe. */ val charging: Boolean?, + /** #689/#815 follow-up: the connect-time GET_DATA_RANGE pages-behind sample, when known. Set once per + * connection, so it doesn't reintroduce per-tick churn either. */ + val pagesBehindAtConnect: Int?, ) @OptIn(ExperimentalMaterial3Api::class) @@ -302,6 +305,7 @@ fun TodayScreen( batteryPct = s.batteryPct, whoop5 = s.whoop5Detected, charging = s.charging, + pagesBehindAtConnect = s.pagesBehindAtConnect, ) } }