Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 20 additions & 8 deletions Strand/BLE/BLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3499,15 +3499,27 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
/// Newest plausible-unix marker in a GET_DATA_RANGE COMMAND_RESPONSE = the strap's newest stored
/// record. Mirrors re/diagnose_biometrics.py: scan u32 LE words in the response body (data starts at
/// frame[7], after [type,seq,cmd]), keep those in the unix range, return the max. nil if none.
static func dataRangeNewestUnix(from frame: [UInt8]) -> Int? {
guard frame.count > 7 else { return nil }
let body = Array(frame[7...]); var newest: Int? = nil; var i = 0
while i + 4 <= body.count {
let w = Int(body[i]) | Int(body[i+1]) << 8 | Int(body[i+2]) << 16 | Int(body[i+3]) << 24
if w >= 1_700_000_000 && w <= 1_900_000_000 { newest = max(newest ?? 0, w) }
i += 4
static func dataRangeNewestUnix(from frame: [UInt8],
wallNowUnix: Int = Int(Date().timeIntervalSince1970)) -> Int? {
guard frame.count >= 4 else { return nil }
// Scan EVERY byte offset (was: 4-byte words from offset 7). The strap's newest-record u32 does not
// always land on that grid — on WHOOP 4 it sits at byte offset 8, so the aligned scan straddled it
// and returned nil, leaving a stale/garbage word latched in strapNewestTs. Harmless until #228 wired
// that value into BackfillPolicy: a FUTURE-reading value now skips the periodic offload, so one bad
// read stalls auto-sync (#451/#928/#1012).
let futureCutoff = wallNowUnix + BackfillContinuation.defaultFutureSkewSeconds
var newestNotFuture: Int? = nil; var newestAny: Int? = nil; var i = 0
while i + 4 <= frame.count {
let w = Int(frame[i]) | Int(frame[i+1]) << 8 | Int(frame[i+2]) << 16 | Int(frame[i+3]) << 24
if w >= 1_700_000_000 && w <= 1_900_000_000 {
newestAny = max(newestAny ?? 0, w)
if w <= futureCutoff { newestNotFuture = max(newestNotFuture ?? 0, w) }
}
i += 1
}
return newest
// Prefer the newest word at/behind the wall clock (a device can't record the future); return the
// future max only if EVERY plausible word is future — a genuinely future-set RTC (#928).
return newestNotFuture ?? newestAny
}

/// The OLDEST plausible record timestamp in a GET_DATA_RANGE frame — the start of the strap's stored
Expand Down
32 changes: 25 additions & 7 deletions android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -797,19 +797,37 @@ class WhoopBleClient(
* record. Mirrors Swift `BLEManager.dataRangeNewestUnix`: scan u32 LE words in the response
* body (starts at frame[7], after [type,seq,cmd]), keep those in the unix range, return max.
*/
fun dataRangeNewestUnix(frame: ByteArray): Long? {
if (frame.size <= 7) return null
var newest: Long? = null
var i = 7
fun dataRangeNewestUnix(
frame: ByteArray,
wallNowUnix: Long = System.currentTimeMillis() / 1000L,
): Long? {
if (frame.size < 4) return null
// Scan EVERY byte offset (was: 4-byte words aligned FROM offset 7). The strap's newest-record
// u32 does not always land on that grid — on WHOOP 4 it sits at byte offset 8, so the aligned
// scan straddled it and returned null, leaving a stale/garbage word from an earlier misaligned
// read latched in [strapNewestTs]. Harmless until #228 wired that value into BackfillPolicy:
// a FUTURE-reading strapNewestTs now SKIPS the automatic periodic/strap offload
// ([isFutureDatedNewest]), so one bad read stalls auto-sync (#451/#928/#1012).
var newestNotFuture: Long? = null
var newestAny: Long? = null
val futureCutoff = wallNowUnix + AUTO_CONTINUE_FUTURE_SKEW_SECONDS
var i = 0
while (i + 4 <= frame.size) {
val w = (frame[i].toLong() and 0xFFL) or
((frame[i + 1].toLong() and 0xFFL) shl 8) or
((frame[i + 2].toLong() and 0xFFL) shl 16) or
((frame[i + 3].toLong() and 0xFFL) shl 24)
if (w in 1_700_000_000L..1_900_000_000L) newest = maxOf(newest ?: 0L, w)
i += 4
if (w in 1_700_000_000L..1_900_000_000L) {
newestAny = maxOf(newestAny ?: 0L, w)
if (w <= futureCutoff) newestNotFuture = maxOf(newestNotFuture ?: 0L, w)
}
i += 1
}
return newest
// A device cannot record the future: prefer the newest word AT/BEHIND the wall clock (the true
// frontier), so a lone future-dated straddle can't hijack it. Only when EVERY plausible word is
// future — a genuinely future-set RTC (#928) — return the future max, so [isFutureDatedNewest]
// still fires and the auto-continue / periodic guards correctly refuse the range.
return newestNotFuture ?: newestAny
}

/** OLDEST plausible record timestamp in a GET_DATA_RANGE frame — the start of the strap's stored
Expand Down
70 changes: 70 additions & 0 deletions android/app/src/test/java/com/noop/ble/DataRangeScanTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.noop.ble

import org.junit.Assert.assertEquals
import org.junit.Test

/**
* [WhoopBleClient.dataRangeNewestUnix] offset/future-date correctness (#451/#928/#1012).
*
* Regression fixtures are David's REAL WHOOP 4.0 GET_DATA_RANGE frames captured 2026-07-11: the newest
* banked record is 2026-07-11 16:00 (unix 1_783_785_625) and it sits at BYTE OFFSET 8 — off the old
* 4-byte-aligned-from-7 grid, so the old scan returned null and a stale FUTURE word stayed latched in
* strapNewestTs, which #228 then used to skip the periodic offload → auto-sync stalled ~25 min.
*/
class DataRangeScanTest {

private fun hex(s: String) = ByteArray(s.length / 2) {
((Character.digit(s[it * 2], 16) shl 4) + Character.digit(s[it * 2 + 1], 16)).toByte()
}

/** Little-endian u32 [value] written at [offset] into a [size]-byte frame (rest zero). */
private fun frameWithU32(size: Int, offset: Int, value: Long): ByteArray {
val b = ByteArray(size)
for (k in 0..3) b[offset + k] = ((value shr (8 * k)) and 0xFF).toByte()
return b
}

private val WALL_NOW = 1_783_786_000L // 2026-07-11 ~16:06, just after the captured newest
private val REAL_NEWEST = 1_783_785_625L // 2026-07-11 16:00:25 (frame a)

@Test
fun `reads the real WHOOP4 newest at byte offset 8 (old scan-from-7 missed it, returned null)`() {
// Three real captured frames → their true newest (offset-8 u32), all 2026-07-11 ~16:00, a few
// seconds apart. Each is well behind WALL_NOW, so none is treated as future.
val expected = mapOf(
"aa100057305d22009968526a083900001d2e2263" to 1_783_785_625L, // 16:00:25
"aa10005730612200a268526ab0290000e87d155d" to 1_783_785_634L, // 16:00:34
"aa100057307c2200e768526a78760000c997138d" to 1_783_785_703L, // 16:01:43
)
for ((h, ts) in expected) {
assertEquals(
"newest for $h should be its offset-8 value, not null",
ts,
WhoopBleClient.dataRangeNewestUnix(hex(h), WALL_NOW),
)
}
}

@Test
fun `a lone future-dated straddle never becomes the frontier`() {
// Real "today" word plus a fabricated far-future word: the true frontier is today, not the future.
val future = WALL_NOW + 400L * 86_400L
val frame = frameWithU32(16, 4, REAL_NEWEST)
for (k in 0..3) frame[10 + k] = ((future shr (8 * k)) and 0xFF).toByte()
assertEquals(REAL_NEWEST, WhoopBleClient.dataRangeNewestUnix(frame, WALL_NOW))
}

@Test
fun `all-future frame (genuinely wrong RTC) still returns future so the guard fires`() {
// The strap's ONLY plausible-unix word is future → return it so isFutureDatedNewest detects the
// real bad-clock case and the periodic/auto-continue guards correctly refuse the range (#928).
val future = WALL_NOW + 400L * 86_400L
assertEquals(future, WhoopBleClient.dataRangeNewestUnix(frameWithU32(12, 4, future), WALL_NOW))
}

@Test
fun `a value just inside the 48h skew is treated as present, not future`() {
val within = WALL_NOW + 40L * 3600L // 40h ahead, under the 48h AUTO_CONTINUE_FUTURE_SKEW
assertEquals(within, WhoopBleClient.dataRangeNewestUnix(frameWithU32(12, 4, within), WALL_NOW))
}
}