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..8eb6d225c6 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.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/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..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,
)
}
}
@@ -1169,6 +1173,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 +2009,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 +2100,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 +2139,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 +2157,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 +2172,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