diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index e650d7af6a..467e4759b8 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -12,11 +12,27 @@ on: paths: - 'android/**' - '.github/workflows/android.yml' + # The twin-drift guards read the SWIFT side's fixtures on purpose — DecoderOracleTest compares + # against Packages/.../decoder_oracle.json to catch a one-sided edit, and the schema/protocol + # oracles work the same way. Without these, editing the Swift copy alone leaves the very test + # written to catch that from ever running. Keep this list in step with what the tests read. + - 'Packages/WhoopProtocol/Sources/WhoopProtocol/Resources/**' + - 'Packages/WhoopProtocol/Tests/WhoopProtocolTests/Resources/**' + - 'Packages/WhoopStore/Tests/WhoopStoreTests/Resources/**' + - 'Strand/Resources/Localizable.xcstrings' push: branches: [main] paths: - 'android/**' - '.github/workflows/android.yml' + # The twin-drift guards read the SWIFT side's fixtures on purpose — DecoderOracleTest compares + # against Packages/.../decoder_oracle.json to catch a one-sided edit, and the schema/protocol + # oracles work the same way. Without these, editing the Swift copy alone leaves the very test + # written to catch that from ever running. Keep this list in step with what the tests read. + - 'Packages/WhoopProtocol/Sources/WhoopProtocol/Resources/**' + - 'Packages/WhoopProtocol/Tests/WhoopProtocolTests/Resources/**' + - 'Packages/WhoopStore/Tests/WhoopStoreTests/Resources/**' + - 'Strand/Resources/Localizable.xcstrings' workflow_dispatch: concurrency: diff --git a/.github/workflows/app-build.yml b/.github/workflows/app-build.yml index 5059dab03f..5778d568d5 100644 --- a/.github/workflows/app-build.yml +++ b/.github/workflows/app-build.yml @@ -13,6 +13,18 @@ on: branches: [main] paths: - 'Strand/**' + # The app-target TESTS, not just the sources they cover. The `Test Strand` step below runs + # StrandTests, but a PR touching only those tests matched nothing here, so it built and ran + # nothing: one that failed to compile, or simply failed, merged unverified and went red on the + # next unrelated PR that happened to touch `Strand/**`, mis-attributing the breakage to whoever + # that was. + # + # This is the only test directory that needs naming, and the reason is layout rather than + # oversight: Android's tests sit under `android/**` and the package suites under `Packages/**`, + # both already inside their subject's glob, while StrandTests is a SIBLING of `Strand/` instead + # of part of it. `project.yml` does not cover it either — the target takes + # `sources: [StrandTests]`, a directory, so adding a test file changes no other tracked path. + - 'StrandTests/**' - 'StrandiOS/**' - 'StrandiOSShared/**' - 'StrandiOSWidgets/**' diff --git a/.github/workflows/swift-packages.yml b/.github/workflows/swift-packages.yml index c46bb25628..cb46672dcf 100644 --- a/.github/workflows/swift-packages.yml +++ b/.github/workflows/swift-packages.yml @@ -16,6 +16,11 @@ on: - 'Tools/SleepPSG/**' - 'Tools/Backfill/**' - '.github/workflows/swift-packages.yml' + # Mirror of the note in android.yml: package tests read the ANDROID oracles to catch a one-sided + # edit, and one reads Strand/Liquid/LiquidCore.swift. Editing only that side would otherwise skip + # the guard written for exactly that case. Keep in step with what the tests read. + - 'android/app/src/test/resources/**' + - 'Strand/Liquid/LiquidCore.swift' push: branches: [main] paths: @@ -24,6 +29,11 @@ on: - 'Tools/SleepPSG/**' - 'Tools/Backfill/**' - '.github/workflows/swift-packages.yml' + # Mirror of the note in android.yml: package tests read the ANDROID oracles to catch a one-sided + # edit, and one reads Strand/Liquid/LiquidCore.swift. Editing only that side would otherwise skip + # the guard written for exactly that case. Keep in step with what the tests read. + - 'android/app/src/test/resources/**' + - 'Strand/Liquid/LiquidCore.swift' workflow_dispatch: concurrency: diff --git a/.github/workflows/tools-python.yml b/.github/workflows/tools-python.yml index fc18f4429a..114bfde183 100644 --- a/.github/workflows/tools-python.yml +++ b/.github/workflows/tools-python.yml @@ -5,22 +5,35 @@ # one of them only ever executed when a contributor remembered to, which meant a decoder could regress and # the suite that would have caught it stayed green by never being run. # -# Cheap to run: pure stdlib `unittest`, no dependencies, no fixtures to fetch, ubuntu rather than macOS. -# Path-filtered so it only fires when the suites it runs change. +# Cheap to run: pure stdlib `unittest`, no dependencies, no fixtures to fetch, ubuntu rather than macOS — +# about 25 seconds end to end. +# +# NOT path-filtered, and that is deliberate. It used to fire only on `Tools/**.py`, on the premise that a +# suite can only break when its own source changes. That is false for `test_home_i18n.py`, which asserts +# on the CONTENTS of app and package files — AppModel.swift, the xcstrings catalogues, ReadinessEngine, +# DayNavBar. A Swift-only commit broke it (#1671 moved a sign and a hardcoded °C out of a localized +# literal) and the filter meant this job never ran, so `main` sat red until a contributor touching +# `Tools/` for something unrelated tripped over it in their own PR (#1691). +# +# Widening the filter to name those paths would just re-create the trap one edit later: the list is +# whatever the assertions happen to read today. Twenty-five seconds on every PR is cheaper than a gate +# that is green because it did not run. name: Tools Python CI on: + # Path-filtered like android.yml / swift-packages.yml / app-build.yml. Without this the workflow ran on + # EVERY pull request and every push to main, including the many that touch no Python at all - and with a + # Windows runner now in it, billed at twice a Linux minute, that is a real cost for a job whose inputs + # cannot have changed. The workflow file itself is included so a change to the job still tests itself. pull_request: branches: [main] paths: - - 'Tools/linux-capture/**' - - 'Tools/*.py' + - 'Tools/**' - '.github/workflows/tools-python.yml' push: branches: [main] paths: - - 'Tools/linux-capture/**' - - 'Tools/*.py' + - 'Tools/**' - '.github/workflows/tools-python.yml' workflow_dispatch: @@ -76,3 +89,36 @@ jobs: exit 1 fi working-directory: Tools + + windows-capture: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + # Keep Windows locale defaults and exercise a strict legacy console, including subprocesses. + # UTF-8 mode would hide implicit file encodings; replacement output would hide console bugs. + - name: Run Tools/linux-capture tests with legacy Windows encodings + working-directory: Tools/linux-capture + env: + PYTHONUTF8: '0' + PYTHONIOENCODING: 'cp1252:strict' + PYTHONLEGACYWINDOWSSTDIO: '1' + shell: python + run: | + import sys + import unittest + + suite = unittest.defaultTestLoader.discover('.', pattern='test_*.py') + count = suite.countTestCases() + print(f'collected {count} tests', flush=True) + result = unittest.TextTestRunner(verbosity=2).run(suite) + if count < 200 or result.testsRun < 200: + print('::error::expected at least 200 tests; discovery or execution is incomplete') + sys.exit(1) + sys.exit(0 if result.wasSuccessful() else 1) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index dbd6f5563c..a84b289370 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -57,11 +57,15 @@ This is long-standing practice, not a one-off. Worked examples already in the tr field order, widths and enum values for `FilteredLabradorPacket` / `RawLabradorPacket`, plus the `{revision, arg, padding}` command payload shape, re-derived from static analysis of the vendor's iOS client. The four command NUMBERS were already in our own `CommandNumber` table from the whoomp/goose - work above. Everything ships as an **unvalidated candidate**: no strap has yet been asked whether it - honours these commands, the decode backs no metric, and the fields nobody can attest (the wrist enum's - raw values, the progress "timed out" sentinel, the ECG sample unit, the packet type byte) are carried - raw rather than named. The on-strap rhythm classifier's verdict is decoded as a byte and is never - presented as a finding — NOOP is not a medical device. + work above. Most of it still ships as an **unvalidated candidate**: the decode backs no metric, and the + fields nobody can attest (the wrist enum's raw values, the progress "timed out" sentinel, the ECG + sample unit, the packet type byte) are carried raw rather than named. **One exception is now attested + rather than inferred:** `ControlSignal`'s raw values were also read off the client's enum order, and a + WHOOP MG (`WS50_r00`, fw `50.39.1.0`) contradicts that reading — `1` stops generation, `2` starts it, + `0` is refused. The enum now carries the hardware values with the device and firmware named. A + hardware-observed value is strictly stronger than the inference it replaces, but it is one device. + The on-strap rhythm classifier's verdict is decoded as a byte and is never presented as a finding — + NOOP is not a medical device. And the line held from the other side: diff --git a/CHANGELOG.md b/CHANGELOG.md index f939edfde5..7bd0c5740d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,66 @@ approximate; downloads are on the [Releases](https://github.com/NoopApp/noop/rel --- +## 11.1.0: A clock you choose, sleep without motion, and logs that report instead of assuming (all platforms) + +**Choose how you read times** + +- **Clock format: System / 12-hour / 24-hour (#1821).** Times were decided by your device's *region*, not + by you. On Apple that is deliberate — it preserves regional date order and clock style — but building a + locale from a region identifier discards the explicit **24-Hour Time** switch in iOS Settings, so + someone in a 24-hour country who preferred a 12-hour clock had no way to ask for one. Settings → + Appearance now offers the choice, defaulting to System, and System now follows your phone's own switch. + It applies to the Asleep/Woke row, workout and nap times, caffeine and hydration entries, backup + timestamps, sleep-stage timelines and the home-screen widgets. Chart axis ticks stay 24-hour for now: + 12-hour labels are wider and the axis spacing needs checking on a device first. + +**Sleep** + +- **A night can be found from heart rate alone (#1801).** Sleep detection is built on stillness, so a + strap that banks no movement scored no nights at all — the everyday reality of a WHOOP 5/MG that will + not complete pairing. NOOP can now detect and stage such a night from heart rate. It is display-only by + design: an HR-only night never feeds resting heart rate, HRV or SDNN baselines, because it was not + established with the evidence those depend on. +- **The AI coach receives your sleep stages (#1816).** It had been answering that it could not see them, + because deep, REM and light minutes were never included in its context. They are now, with efficiency + normalised so it cannot arrive as a nonsensical percentage. + +**Straps, logs and honesty** + +- **The 1970/71 clock warning takes battery into account (#1818).** It previously offered one remedy at + any charge level, so people already at 100% were told to charge to 100% and reconnect — advice that was + not backed by any precondition in the code. +- **Every disconnect records what actually happened (#1809, thanks @supremesynergy).** How long the link + held, how many frames and bytes arrived, whether the realtime stream was armed, and how it ended. A + strap log could not previously state whether a strap had transmitted anything; you had to infer it from + every logged line happening to be outgoing, which measures NOOP's logging rather than the strap. +- **The clock exchange quotes the strap's reply (#1823).** NOOP wrote "clock synced" the moment it queued + the write, before any answer existed, so a log could assert the clock was set while the connection + readout said 1970/71. It now says what it *sent* and records the reply frame. On a WHOOP 5/MG the + readout also claimed "RTC reads 1970/71" when no clock had ever been read on that family; it now + reports the evidence it has, which is how the strap dated its records. + +**Backup, widgets and interface** + +- **Oversize backups (#1807).** Export warns when an archive exceeds the 2 GiB restore ceiling instead of + writing one that cannot be restored, and an oversize restore can be allowed through rather than refused. +- **Home-screen widgets (#1795, thanks @Sneheth; #1799).** Heart-rate and HRV values had swapped icons on + Apple. Both platforms' widgets now also read aloud properly, speaking the value rather than a bare + number. +- **Live workout no longer shows two overlapping timers (#1814),** and the steps calibration card stops + asking for data already provided (#1815). +- **Oura rings that advertise no name (#1797, thanks @pipiche38)** label consistently across platforms, + and Android's standard heart-rate logging is honest about how much it captured (#1796, thanks @kvnloo). + +**Still open** + +WHOOP 5/MG history remains unsolved. A strap that will not complete the pairing handshake is never given +a clock, and an un-clocked 5/MG does not persist sensor data to flash, so its offloads complete empty. +The diagnostics above exist to separate that from a strap that *was* clocked and still refuses — a +different fault seen on the same firmware. + +--- + ## 9.3.0: Water and caffeine import, honest Effort, and an Oura resting-heart-rate fix (all platforms) A release about the numbers being right, on top of 9.2.1. diff --git a/CLAUDE.md b/CLAUDE.md index 6028f53dfc..532c7d577b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,14 +7,18 @@ and [`docs/IOS.md`](docs/IOS.md) covers the iOS target. Read this first; follow ## What NOOP is (and the hard scope limits) -NOOP is a **fully offline, on-device** companion app for WHOOP 4.0 and 5.0/MG straps (with +NOOP is an **offline-by-default, on-device** companion app for WHOOP 4.0 and 5.0/MG straps (with **experimental** Oura support in the tree — gated behind `ExperimentalBrand`, not a shipped supported strap). It pairs over Bluetooth, stores everything in on-device SQLite, and computes recovery / strain -/ HRV / sleep locally. There is **no server, no account, no cloud sync, no telemetry**, and the project stays -**anonymous** (iOS/Android ship build-from-source / sideload, not via the App Store). +/ HRV / sleep locally. There is **no NOOP-operated server, no account, no cloud dependency, no +telemetry**, and the project stays **anonymous** (iOS/Android ship build-from-source / sideload, not +via the App Store). Issue #1314 permits one narrow exception: a default-off Experimental client may +export data one way to an HTTP(S) endpoint the user owns and configures. It must remain outside strap +sync, never read data back, and ship no receiver or hosted service in this repository. These are hard constraints, not preferences. A PR is out of scope if it: -- adds a server, account, cloud sync, or sends any data off-device; +- adds a server, account, cloud dependency, or sends data off-device without the explicit user export + boundary in [`docs/SCOPE.md`](docs/SCOPE.md) (including #1314's one-way self-hosted push); - adds analytics/telemetry/crash-reporting that phones home; - adds WHOOP firmware, decompiled app code, logos/assets, or any DRM circumvention. NOOP is **clean-room interoperability** with hardware the user owns — keep it that way. (That bars @@ -115,7 +119,7 @@ xcodegen generate && xcodebuild -project Strand.xcodeproj -scheme Strand \ ### What each CI job covers — and the gaps | Workflow | Covers | Runner | Default state | |---|---|---|---| -| `swift-packages.yml` | `swift test` for **`Packages/**` only** (WhoopProtocol, WhoopStore, StrandAnalytics, StrandImport, StrandDesign, NoopLocalAccess) | macos-15 | **active** | +| `swift-packages.yml` | TWO jobs. `test`: `swift test` over **`Packages/**`** (WhoopProtocol, WhoopStore, StrandAnalytics, StrandImport, StrandDesign, NoopLocalAccess). `tools`: `swift build` + `swift test` over **`Tools/SleepBench`, `Tools/SleepPSG`, `Tools/Backfill`** — Backfill has no test target, so it is build-only. Path-filtered to those directories. | macos-15 | **active** | | `app-build.yml` | Builds the **app targets** (`Strand` macOS + `NOOPiOS` iOS) **and runs `StrandTests`** on the macOS/`Strand` leg only — the iOS leg is compile-only. iOS leg needs **macos-26** (iOS 26 SDK / `glassEffect`). | macos-15 / macos-26 | **disabled** (on-demand) | | `android.yml` | `assembleFullDebug` + `testFullDebugUnitTest` | ubuntu | **active**, path-filtered to `android/**` | | `source-hygiene.yml` | Doc comments that bind to nothing (`Tools/doc_comment_lint.py`) | ubuntu | **active** | @@ -132,10 +136,13 @@ properties are initialized`) will pass every green check and still be broken. If Swift, you MUST build the app yourself: `xcodebuild … build` locally, or run `app-build.yml` on demand. ### Local walls (things that will *not* build where you expect) -- **On Linux:** only `WhoopProtocol` / `OuraProtocol` (pure) build & test. Every GRDB-linked package — - `WhoopStore`, `StrandImport`, `StrandAnalytics` (via `WhoopStore`), and `NoopLocalAccess` — fails with - `sqlite3.h not found` (GRDB's CSQLite), and `StrandDesign` needs SwiftUI — all need **macOS**. Android - JVM unit tests **do** run on Linux. +- **On Linux:** `WhoopProtocol` / `OuraProtocol` (pure) build & test with a bare toolchain. The + GRDB-linked packages need the snapshot-enabled SQLite build in [`docs/BUILD.md`](docs/BUILD.md) — with + it, all four build AND test: `StrandAnalytics` (1523), `WhoopStore` (439), `StrandImport` (249) and + `NoopLocalAccess` (9). Without those flags they fail with `sqlite3.h not found` (GRDB's CSQLite). `StrandDesign` + needs SwiftUI and is macOS-only. Android JVM unit tests **do** run on Linux. + **None of this is CI-enforced** — `swift-packages.yml` is macOS-only, so Linux support is honour-system + and a change can break it silently. - **App targets** (`Strand`, `NOOPiOS`) need **Xcode on macOS**; `StrandTests` runs only under `xcodebuild … test` on macOS — locally, or via `app-build.yml`, which does run it on the `Strand` leg. Since that workflow is **disabled by default**, app-target tests are only as validated as your last diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5c4355bd42..863f24dbca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,13 +60,32 @@ cd android ## What CI checks -Two GitHub Actions workflows run on every PR and push to `main`. They compile and -run unit tests only — no code signing, no secrets, no release. +Some GitHub Actions workflows run on *every* PR; others are path-filtered and only run when +you touch what they cover. All of them compile and run unit tests only — no code signing, no +secrets, no release. The table is the source of truth, deliberately — the section this +replaced led with a count, and a count in prose is what goes stale while the list below it +looks fine. -| Workflow | Trigger | What it does | +The check names GitHub shows you are **job** names, which do not resemble the workflow +names. That column is why this table exists: + +| Check you see | Workflow | Runs when | |---|---|---| -| **Swift Packages CI** (`.github/workflows/swift-packages.yml`) | changes under `Packages/**` | `swift build` + `swift test` for each package | -| **Android CI** (`.github/workflows/android.yml`) | changes under `android/**` | `assembleFullDebug` + `testFullDebugUnitTest` (JDK 17) | +| `check` | **i18n Coverage** (`i18n-coverage.yml`) | every PR | +| `doc-comments` | **Source Hygiene** (`source-hygiene.yml`) | every PR | +| `linux-capture` | **Tools Python CI** (`tools-python.yml`) | every PR | +| `build-and-test` | **Android CI** (`android.yml`) | `android/**`, the protocol/store test resources, `Strand/Resources/Localizable.xcstrings` | +| `test (…)`, `tools (…)` | **Swift Packages CI** (`swift-packages.yml`) | `Packages/**`, the `Tools/SleepBench`, `Tools/SleepPSG` and `Tools/Backfill` packages, `android/app/src/test/resources/**`, `Strand/Liquid/LiquidCore.swift` | + +Read that as a worked example: an Android-only PR runs Android CI plus the three that always +run, so a short list of checks does not mean little was checked. + +**App build** (`app-build.yml`, app-target compile + the `StrandTests` macOS suite) is +`disabled_manually` and is **not** in that list. App-target code — SwiftUI views, +`BLEManager`, `Repository`, Compose screens — is compiled by **nothing** on a normal PR, so +build it locally before you push, or ask a maintainer to dispatch `app-build.yml`. See +[docs/CONTRIBUTING.md](docs/CONTRIBUTING.md#what-ci-gates--and-what-it-deliberately-doesnt) +for why the lean setup is deliberate and what else is gated at release time instead. If CI fails on your PR, fix the cause rather than working around it. Never commit generated output (`Strand.xcodeproj/`) or any secrets, keystores, or `local.properties`. diff --git a/Packages/NoopLocalAccess/README.md b/Packages/NoopLocalAccess/README.md new file mode 100644 index 0000000000..82fef2efc7 --- /dev/null +++ b/Packages/NoopLocalAccess/README.md @@ -0,0 +1,30 @@ +# NoopLocalAccess + +`noop-local-access` exposes bounded, read-only NOOP health data locally. It has no network or +write/control path. + +Use MCP over stdio with `noop-local-access mcp`, or query one tool directly as JSON: + +```sh +noop-local-access query health_snapshot --days 14 +noop-local-access query metric_series --key hrv --days 90 +noop-local-access query data_freshness +noop-local-access query sleep_summary --days 30 +noop-local-access query workout_summary --days 90 +``` + +Set `NOOP_DB_PATH` to select a database, or pass `--db-path PATH`. Query results are written to +stdout; diagnostics are written to stderr. Query usage errors exit 64 and runtime/database errors +exit 1. + +Arguments reuse the MCP defaults and bounds: + +- `health_snapshot`: optional `--days` (default 14, clamped to 1...120). +- `metric_series`: required `--key`; optional `--source` (default `my-whoop`), `--days` (default 90, + clamped to 1...4000), `--from-day`, `--to-day`, and `--limit` (default 500, clamped to 1...2000). +- `data_freshness`: no tool arguments. +- `sleep_summary`: optional `--days` (default 30, clamped to 1...4000). +- `workout_summary`: optional `--days` (default 90, clamped to 1...4000). + +Dates use the existing `YYYY-MM-DD` tool contract. The CLI does not add a separate validation or +interpretation layer; it passes accepted arguments to the same bounded read-only dispatcher as MCP. diff --git a/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/CLIQuery.swift b/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/CLIQuery.swift new file mode 100644 index 0000000000..36ac3d69a5 --- /dev/null +++ b/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/CLIQuery.swift @@ -0,0 +1,118 @@ +import Foundation + +public struct NoopCLIQueryRequest: Equatable, Sendable { + public let toolName: String + public let arguments: [String: JSONValue] + public let configuration: LocalAccessConfiguration + + public init( + toolName: String, + arguments: [String: JSONValue], + configuration: LocalAccessConfiguration = .environment() + ) { + self.toolName = toolName + self.arguments = arguments + self.configuration = configuration + } +} + +public enum NoopCLIQueryError: Error, CustomStringConvertible, Equatable { + case usage(String) + + public var description: String { + switch self { + case .usage(let message): return message + } + } + + public var exitCode: Int32 { 64 } +} + +public enum NoopCLIQuery { + public static func parse(arguments: [String]) throws -> NoopCLIQueryRequest { + guard let toolName = arguments.first, !toolName.hasPrefix("-") else { + throw NoopCLIQueryError.usage("query requires one tool name") + } + guard NoopToolDispatcher.toolNames.contains(toolName) else { + throw NoopCLIQueryError.usage("unknown query tool") + } + + var toolArguments: [String: JSONValue] = [:] + var configuration = LocalAccessConfiguration.environment() + var seenFlags = Set() + var index = 1 + + while index < arguments.count { + let flag = arguments[index] + guard flag.hasPrefix("--") else { + throw NoopCLIQueryError.usage("query does not accept additional positional arguments") + } + guard seenFlags.insert(flag).inserted else { + throw NoopCLIQueryError.usage("duplicate query flag: \(flag)") + } + index += 1 + + switch flag { + case "--db-path": + configuration.databasePath = try requiredValue(flag, arguments: arguments, index: &index) + case "--days": + guard toolName != "data_freshness" else { throw unsupported(flag, toolName: toolName) } + toolArguments["days"] = try integerValue(flag, arguments: arguments, index: &index) + case "--key": + guard toolName == "metric_series" else { throw unsupported(flag, toolName: toolName) } + toolArguments["key"] = .string(try requiredValue(flag, arguments: arguments, index: &index)) + case "--source": + guard toolName == "metric_series" else { throw unsupported(flag, toolName: toolName) } + toolArguments["source"] = .string(try requiredValue(flag, arguments: arguments, index: &index)) + case "--from-day": + guard toolName == "metric_series" else { throw unsupported(flag, toolName: toolName) } + toolArguments["from_day"] = .string(try requiredValue(flag, arguments: arguments, index: &index)) + case "--to-day": + guard toolName == "metric_series" else { throw unsupported(flag, toolName: toolName) } + toolArguments["to_day"] = .string(try requiredValue(flag, arguments: arguments, index: &index)) + case "--limit": + guard toolName == "metric_series" else { throw unsupported(flag, toolName: toolName) } + toolArguments["limit"] = try integerValue(flag, arguments: arguments, index: &index) + default: + throw NoopCLIQueryError.usage("unknown query flag") + } + } + + if toolName == "metric_series", toolArguments["key"] == nil { + throw NoopCLIQueryError.usage("metric_series requires --key") + } + + return NoopCLIQueryRequest(toolName: toolName, arguments: toolArguments, configuration: configuration) + } + + public static func dispatch(_ request: NoopCLIQueryRequest) throws -> JSONValue { + try NoopToolDispatcher(configuration: request.configuration) + .dispatch(name: request.toolName, arguments: request.arguments) + } + + public static func encodeLine(_ value: JSONValue) throws -> Data { + var data = try JSONEncoder().encode(value) + data.append(0x0A) + return data + } + + private static func requiredValue(_ flag: String, arguments: [String], index: inout Int) throws -> String { + guard index < arguments.count, !arguments[index].hasPrefix("--") else { + throw NoopCLIQueryError.usage("missing value for \(flag)") + } + defer { index += 1 } + return arguments[index] + } + + private static func integerValue(_ flag: String, arguments: [String], index: inout Int) throws -> JSONValue { + let raw = try requiredValue(flag, arguments: arguments, index: &index) + guard let value = Int(raw) else { + throw NoopCLIQueryError.usage("value for \(flag) must be an integer") + } + return .int(value) + } + + private static func unsupported(_ flag: String, toolName: String) -> NoopCLIQueryError { + .usage("\(flag) is not supported for \(toolName)") + } +} diff --git a/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/LocalAccessCore.swift b/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/LocalAccessCore.swift index 632e181a28..db34c46f3d 100644 --- a/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/LocalAccessCore.swift +++ b/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/LocalAccessCore.swift @@ -323,16 +323,29 @@ public final class ReadonlyNoopStore { try dbQueue.read { db in // Every durable decoded per-second table. The four below the first line were landed as // instrumentation and then left OUT of this count, which is how an unbounded, unpruned table - // became invisible: `ppgWaveformSample` banks a ~48-byte BLOB per v26 strap-second and - // `v18AuxSample` ~30 bytes per v18 strap-second, neither is pruned (deliberately — this is - // decoded biometric history, not the transient raw outbox), and until now neither showed up in - // the only readout a user has for "what is the store spending space on". Growth that nothing - // reads still has to be growth somebody can SEE. Guarded by `tableNames.contains` so a store - // predating any of these migrations still reports. + // became invisible: `ppgWaveformSample` banks a ~48-byte BLOB per v26 strap-second, and until + // it was counted it did not show up in the only readout a user has for "what is the store + // spending space on". Growth that nothing reads still has to be growth somebody can SEE. + // + // BOTH blob tables are now bounded, and neither claim below is safe to let go stale, because + // "which tables are already bounded" is the first thing a retention design reads off — getting + // it wrong has such a design solving a problem twice, or trusting a bound that was never + // there. `v18AuxSample`: rolling retention since `WhoopStore.v18AuxRetentionRows` / + // `v18AuxPruneEveryRows` (Room migration v31, covered by `DeepCaptureChannelsTests`). + // `ppgWaveformSample`: rolling retention since #1911 (`WhoopStore.ppgWaveformRetentionRows` / + // `ppgWaveformPruneEveryRows`, covered by `PpgWaveformSampleTests`). Both keep ~604,800 NEWEST + // rows per device — a row cap, NOT an age cutoff, so a sporadic wearer keeps a full working + // set. Every other decoded per-second table listed here is still genuinely unpruned. + // + // This list is a DELIBERATE fourth copy, not drift. The other three (`WhoopStore.rawTableKeys`, + // the footprint total and the timestamp heal) now share one list; this module cannot, because + // NoopLocalAccess depends on GRDB alone and stays independent of WhoopStore on purpose — it is + // the read-only access tool, and a dependency on the app's store package would defeat that. + // Guarded by `tableNames.contains` so a store predating any of these migrations still reports. let decodedTables = [ "hrSample", "rrInterval", "event", "battery", "spo2Sample", "skinTempSample", "respSample", "gravitySample", "ppgHrSample", "stepSample", - "sleepStateSample", "ppgWaveformSample", "rawImuSample", "v18AuxSample", + "sleepStateSample", "ppgWaveformSample", "v18AuxSample", ] var decodedRows = 0 for table in decodedTables where tableNames.contains(table) { diff --git a/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/MCPServer.swift b/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/MCPServer.swift index a5d34a5557..8509f0cd21 100644 --- a/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/MCPServer.swift +++ b/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/MCPServer.swift @@ -17,11 +17,10 @@ public struct RPCRequest: Decodable, Equatable { } public final class NoopMCPServer { - private let configuration: LocalAccessConfiguration - private var dataAccess: NoopDataAccess? + private let dispatcher: NoopToolDispatcher public init(configuration: LocalAccessConfiguration = .environment()) { - self.configuration = configuration + dispatcher = NoopToolDispatcher(configuration: configuration) } public func handleLine(_ line: String) -> JSONValue { @@ -113,19 +112,6 @@ public final class NoopMCPServer { NOOP local access is read-only and returns personal health context from the user's on-device SQLite store. Use bounded tools, check data_freshness before stale-data claims, separate facts from inference, and do not diagnose medical conditions. No tool writes data or calls a network service. """ - private func data() throws -> NoopDataAccess { - if let dataAccess { return dataAccess } - do { - let access = try NoopDataAccess.open(configuration: configuration) - dataAccess = access - return access - } catch let error as LocalAccessError { - throw error - } catch { - throw LocalAccessError.databaseUnavailable("NOOP database is not available: \(error)") - } - } - private func callTool(params: JSONValue?) throws -> JSONValue { guard let object = params?.objectValue, let name = object["name"]?.stringValue @@ -133,51 +119,14 @@ public final class NoopMCPServer { throw LocalAccessError.invalidParams("tools/call requires a tool name") } let arguments = object["arguments"]?.objectValue ?? [:] - let payload: JSONValue - switch name { - case "health_snapshot": - payload = try data().healthSnapshot(days: boundedDays(arguments["days"], default: 14, max: 120)) - case "metric_series": - guard let key = arguments["key"]?.stringValue else { - throw LocalAccessError.invalidParams("metric_series requires key") - } - payload = try data().metricSeries( - key: key, - source: arguments["source"]?.stringValue ?? "my-whoop", - days: boundedDays(arguments["days"], default: 90, max: 4000), - fromDay: arguments["from_day"]?.stringValue, - toDay: arguments["to_day"]?.stringValue, - limit: boundedLimit(arguments["limit"], default: 500, max: 2000) - ) - case "data_freshness": - payload = try data().freshness() - case "sleep_summary": - payload = try data().sleepSummary(days: boundedDays(arguments["days"], default: 30, max: 4000)) - case "workout_summary": - payload = try data().workoutSummary(days: boundedDays(arguments["days"], default: 90, max: 4000)) - default: - throw LocalAccessError.toolNotFound(name) - } - return toolResult(payload) + return toolResult(try dispatcher.dispatch(name: name, arguments: arguments)) } private func readResource(params: JSONValue?) throws -> JSONValue { guard let uri = params?.objectValue?["uri"]?.stringValue else { throw LocalAccessError.invalidParams("resources/read requires uri") } - let payload: JSONValue - switch uri { - case "noop://health/snapshot": - payload = try data().healthSnapshot(days: 14) - case "noop://data/freshness": - payload = try data().freshness() - case "noop://metrics/catalog": - payload = NoopDataAccess.metricCatalog() - case "noop://sources": - payload = NoopDataAccess.sources() - default: - throw LocalAccessError.resourceNotFound(uri) - } + let payload = try dispatcher.resourcePayload(uri: uri) return .object([ "contents": .array([ .object([ diff --git a/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/ToolDispatcher.swift b/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/ToolDispatcher.swift new file mode 100644 index 0000000000..634ebaeaad --- /dev/null +++ b/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/ToolDispatcher.swift @@ -0,0 +1,74 @@ +import Foundation + +/// The single read-only dispatch surface shared by MCP and the direct CLI transport. +public final class NoopToolDispatcher { + private let configuration: LocalAccessConfiguration + private var dataAccess: NoopDataAccess? + + public init(configuration: LocalAccessConfiguration = .environment()) { + self.configuration = configuration + } + + public static let toolNames = [ + "health_snapshot", + "metric_series", + "data_freshness", + "sleep_summary", + "workout_summary", + ] + + public func dispatch(name: String, arguments: [String: JSONValue] = [:]) throws -> JSONValue { + switch name { + case "health_snapshot": + return try data().healthSnapshot(days: boundedDays(arguments["days"], default: 14, max: 120)) + case "metric_series": + guard let key = arguments["key"]?.stringValue else { + throw LocalAccessError.invalidParams("metric_series requires key") + } + return try data().metricSeries( + key: key, + source: arguments["source"]?.stringValue ?? "my-whoop", + days: boundedDays(arguments["days"], default: 90, max: 4000), + fromDay: arguments["from_day"]?.stringValue, + toDay: arguments["to_day"]?.stringValue, + limit: boundedLimit(arguments["limit"], default: 500, max: 2000) + ) + case "data_freshness": + return try data().freshness() + case "sleep_summary": + return try data().sleepSummary(days: boundedDays(arguments["days"], default: 30, max: 4000)) + case "workout_summary": + return try data().workoutSummary(days: boundedDays(arguments["days"], default: 90, max: 4000)) + default: + throw LocalAccessError.toolNotFound(name) + } + } + + public func resourcePayload(uri: String) throws -> JSONValue { + switch uri { + case "noop://health/snapshot": + return try dispatch(name: "health_snapshot", arguments: ["days": .int(14)]) + case "noop://data/freshness": + return try dispatch(name: "data_freshness") + case "noop://metrics/catalog": + return NoopDataAccess.metricCatalog() + case "noop://sources": + return NoopDataAccess.sources() + default: + throw LocalAccessError.resourceNotFound(uri) + } + } + + private func data() throws -> NoopDataAccess { + if let dataAccess { return dataAccess } + do { + let access = try NoopDataAccess.open(configuration: configuration) + dataAccess = access + return access + } catch let error as LocalAccessError { + throw error + } catch { + throw LocalAccessError.databaseUnavailable("NOOP database is not available: \(error)") + } + } +} diff --git a/Packages/NoopLocalAccess/Sources/noop-local-access/main.swift b/Packages/NoopLocalAccess/Sources/noop-local-access/main.swift index 5f95ed2f44..94730f6423 100644 --- a/Packages/NoopLocalAccess/Sources/noop-local-access/main.swift +++ b/Packages/NoopLocalAccess/Sources/noop-local-access/main.swift @@ -11,6 +11,8 @@ enum NoopLocalAccessMain { switch command { case "mcp": runMCP(configuration: .environment()) + case "query": + runQuery(arguments: args) case "codex-config": print(codexConfig(arguments: args)) case "--help", "-h", "help": @@ -21,6 +23,33 @@ enum NoopLocalAccessMain { } } + private static func runQuery(arguments: [String]) { + if arguments == ["--help"] || arguments == ["-h"] { + print(helpText) + return + } + do { + let request = try NoopCLIQuery.parse(arguments: arguments) + let payload = try NoopCLIQuery.dispatch(request) + FileHandle.standardOutput.write(try NoopCLIQuery.encodeLine(payload)) + } catch let error as NoopCLIQueryError { + fputs("[noop-local-access] \(error)\n", stderr) + Foundation.exit(error.exitCode) + } catch let error as LocalAccessError { + let message: String + if case .databaseUnavailable = error { + message = "NOOP database is unavailable" + } else { + message = error.description + } + fputs("[noop-local-access] \(message)\n", stderr) + Foundation.exit(1) + } catch { + fputs("[noop-local-access] runtime error\n", stderr) + Foundation.exit(1) + } + } + private static func runMCP(configuration: LocalAccessConfiguration) { let server = NoopMCPServer(configuration: configuration) while let line = readLine(strippingNewline: true) { @@ -77,8 +106,19 @@ enum NoopLocalAccessMain { private static let helpText = """ Usage: noop-local-access mcp + noop-local-access query [flags] noop-local-access codex-config [--db-path /absolute/path/to/whoop.sqlite] + Query tools: + health_snapshot [--days N] + metric_series --key KEY [--source SOURCE] [--days N] [--from-day YYYY-MM-DD] [--to-day YYYY-MM-DD] [--limit N] + data_freshness + sleep_summary [--days N] + workout_summary [--days N] + + Query options: + --db-path PATH Explicit NOOP SQLite path. Otherwise NOOP_DB_PATH or the official app container is used. + Environment: NOOP_DB_PATH Explicit NOOP SQLite path. Optional; otherwise the official macOS app container is used. NOOP_BUNDLE_ID Optional non-default bundle id. Not needed for the official app. diff --git a/Packages/NoopLocalAccess/Tests/NoopLocalAccessCoreTests/CLIQueryTests.swift b/Packages/NoopLocalAccess/Tests/NoopLocalAccessCoreTests/CLIQueryTests.swift new file mode 100644 index 0000000000..0e2ea7d628 --- /dev/null +++ b/Packages/NoopLocalAccess/Tests/NoopLocalAccessCoreTests/CLIQueryTests.swift @@ -0,0 +1,126 @@ +import XCTest +@testable import NoopLocalAccessCore + +final class CLIQueryTests: XCTestCase { + func testAllFiveQueriesDispatchThroughTheSamePayloadAsMCP() throws { + let url = try TemporaryDatabase.seeded() + let configuration = LocalAccessConfiguration(databasePath: url.path) + let queries: [(String, [String])] = [ + ("health_snapshot", ["--days", "14"]), + ("metric_series", ["--key", "hrv", "--from-day", "2026-06-10", "--to-day", "2026-06-11"]), + ("data_freshness", []), + ("sleep_summary", ["--days", "30"]), + ("workout_summary", ["--days", "90"]), + ] + + for (tool, flags) in queries { + let parsed = try NoopCLIQuery.parse(arguments: [tool] + flags) + let request = NoopCLIQueryRequest( + toolName: parsed.toolName, + arguments: parsed.arguments, + configuration: configuration + ) + let cliPayload = try NoopCLIQuery.dispatch(request) + let response = try XCTUnwrap(try NoopMCPServer(configuration: configuration).handle(RPCRequest( + id: .int(1), + method: "tools/call", + params: .object([ + "name": .string(tool), + "arguments": .object(parsed.arguments), + ]) + ))) + let mcpPayload = try XCTUnwrap(response.objectValue?["result"]?.objectValue?["structuredContent"]) + + XCTAssertEqual( + withoutVolatileFields(mcpPayload), + withoutVolatileFields(cliPayload), + "CLI/MCP payload mismatch for \(tool)" + ) + } + } + + func testMetricSeriesRequiresKeyAndRejectsUnknownOrMissingFlags() { + assertUsageError([]) + assertUsageError(["metric_series"]) + assertUsageError(["metric_series", "--unknown", "x"]) + assertUsageError(["metric_series", "--key=hrv"]) + assertUsageError(["metric_series", "--key"]) + assertUsageError(["metric_series", "--key", "hrv", "--limit"]) + assertUsageError(["metric_series", "--key", "hrv", "--key", "rhr"]) + assertUsageError(["metric_series", "--key", "hrv", "extra"]) + assertUsageError(["health_snapshot", "--db-path"]) + assertUsageError(["data_freshness", "--days", "7"]) + assertUsageError(["health_snapshot", "--days", "not-an-integer"]) + } + + func testMetricSeriesParsesTheCompleteFlagContract() throws { + let parsed = try NoopCLIQuery.parse(arguments: [ + "metric_series", + "--key", "hrv", + "--source", "apple-health", + "--days", "30", + "--from-day", "2026-06-01", + "--to-day", "2026-06-30", + "--limit", "42", + "--db-path", "/tmp/noop.sqlite", + ]) + + XCTAssertEqual(parsed.toolName, "metric_series") + XCTAssertEqual(parsed.arguments, [ + "key": .string("hrv"), + "source": .string("apple-health"), + "days": .int(30), + "from_day": .string("2026-06-01"), + "to_day": .string("2026-06-30"), + "limit": .int(42), + ]) + XCTAssertEqual(parsed.configuration.databasePath, "/tmp/noop.sqlite") + } + + func testCLIValuesArePassedToTheExistingBounds() throws { + let parsed = try NoopCLIQuery.parse(arguments: ["sleep_summary", "--days", "0"]) + XCTAssertEqual(parsed.arguments["days"], .int(0)) + + let request = NoopCLIQueryRequest( + toolName: parsed.toolName, + arguments: parsed.arguments, + configuration: LocalAccessConfiguration(databasePath: try TemporaryDatabase.seeded().path) + ) + let payload = try NoopCLIQuery.dispatch(request) + XCTAssertEqual(payload.objectValue?["range"]?.objectValue?["days"], .int(1)) + } + + func testSuccessfulOutputIsOneJSONValueAndEncodingFailuresPropagate() throws { + let value: JSONValue = .object(["ok": .bool(true)]) + let line = try NoopCLIQuery.encodeLine(value) + + XCTAssertEqual(line.last, 0x0A) + XCTAssertEqual(try JSONDecoder().decode(JSONValue.self, from: Data(line.dropLast())), value) + XCTAssertThrowsError(try NoopCLIQuery.encodeLine(.double(.infinity))) + } + + private func assertUsageError(_ arguments: [String], file: StaticString = #filePath, line: UInt = #line) { + XCTAssertThrowsError(try NoopCLIQuery.parse(arguments: arguments), file: file, line: line) { error in + guard let error = error as? NoopCLIQueryError else { + return XCTFail("Expected usage error, got \(error)", file: file, line: line) + } + XCTAssertEqual(error.exitCode, 64, file: file, line: line) + } + } + + private func withoutVolatileFields(_ value: JSONValue) -> JSONValue { + switch value { + case .array(let values): + return .array(values.map(withoutVolatileFields)) + case .object(let object): + let volatile = Set(["generatedAt", "ageSeconds", "fromTs", "toTs"]) + var normalized: [String: JSONValue] = [:] + for (key, value) in object where !volatile.contains(key) { + normalized[key] = withoutVolatileFields(value) + } + return .object(normalized) + default: + return value + } + } +} diff --git a/Packages/OuraProtocol/Sources/OuraProtocol/Commands.swift b/Packages/OuraProtocol/Sources/OuraProtocol/Commands.swift index a63f55a0f1..0c75875db3 100644 --- a/Packages/OuraProtocol/Sources/OuraProtocol/Commands.swift +++ b/Packages/OuraProtocol/Sources/OuraProtocol/Commands.swift @@ -20,8 +20,12 @@ public enum OuraCommands { public static let featureDaytimeHR: UInt8 = 0x02 // The SpO2 feature id. Per OURA_PROTOCOL.md s7.1. public static let featureSpO2: UInt8 = 0x04 - // The real-steps feature id. Server-flag-gated (activity/real_steps, default off), so it is never - // emitted for an offline NOOP-only ring. Per OURA_PROTOCOL.md s7.1 / s7.3 [open_oura-feat]. + // The real-steps feature id (`activity/real_steps`). Nominally server-flag-gated per + // OURA_PROTOCOL.md s7.1/s7.3 [open_oura-feat], but on-device 2026-08-25 real-steps status reads + // came back enabled (status=1) from BOTH an authenticated Oura-app session and NOOP's own fully + // offline, unauthenticated read of the same ring — the gate is ring-side state, not tied to which + // client asks or whether that client is cloud-authenticated. Do not assume "off for NOOP" without + // checking a live read. public static let featureRealSteps: UInt8 = 0x0B // MARK: - Pre-auth / identity (unauthenticated OK) @@ -148,9 +152,11 @@ public enum OuraCommands { OuraCommand(label: "spo2_status", bytes: [0x2F, 0x02, 0x20, featureSpO2]) } - /// Read the real-steps feature status, `2f 02 20 0b` (READ verb, not enable). The `0x21` reply confirms - /// the server-flag gate (`activity/real_steps`, default off) from the ring itself. Read-only diagnostic. - /// [open_oura-feat] + /// Read the real-steps feature status, `2f 02 20 0b` (READ verb, not enable). The `0x21` reply reports + /// the ring's own real_steps gate state — NOT reliably "off" for an offline ring: on-device + /// 2026-08-25 this read back status=1 (enabled) from NOOP's own unauthenticated connection, matching + /// the real Oura app's read of the same ring byte-for-byte. Read-only diagnostic either way — never + /// enables anything, never writes a mode. [open_oura-feat] public static func realStepsReadStatus() -> OuraCommand { OuraCommand(label: "realsteps_status", bytes: [0x2F, 0x02, 0x20, featureRealSteps]) } diff --git a/Packages/OuraProtocol/Sources/OuraProtocol/OuraDriver.swift b/Packages/OuraProtocol/Sources/OuraProtocol/OuraDriver.swift index dd1b13a018..10e6258e7d 100644 --- a/Packages/OuraProtocol/Sources/OuraProtocol/OuraDriver.swift +++ b/Packages/OuraProtocol/Sources/OuraProtocol/OuraDriver.swift @@ -246,17 +246,34 @@ public final class OuraDriver { return Int(seconds) } + /// How far the ring's clock may run AHEAD of `lowerBoundTicks` and still be recognised. The bound is a + /// stale resume cursor or the oldest ring-time a drain has seen, either of which can trail the ring's + /// clock by the ring's whole banked depth (~14 days) plus however long the cursor has been stuck — the + /// original 7-day window silently excluded exactly that case: in the 2026-09-02/03 iOS captures an + /// 8.2-day-stale cursor could never be re-anchored, so it could never advance, so the staleness only + /// grew — one full re-serve of the same window per launch, forever. Widening costs no + /// honesty: both readings fit the window only when `window >= 9 × lowerBound`, i.e. a ring under ~5 + /// days of clock — and that case still resolves to nil below, exactly as before. + public static let syncTimeAnchorWindowTicks: Int64 = 38_880_000 // 45 days of 100 ms ticks + /// Resolve the 0x13 SyncTime-response device timestamp into ring TICKS, or nil when no unambiguous /// reading exists. ringverse BLE.md labels the field "seconds" but the ring's record clock runs in /// 100 ms ticks, so both readings are tried: the raw value (already ticks) and value×10 (seconds→ - /// ticks). The ring's clock at connect must sit shortly AFTER where the last drain ended, so a - /// candidate is plausible iff it falls in `[historyCursor, historyCursor + 7 days]`; exactly one - /// must fit (ambiguity or a fresh/reset cursor → nil → the caller logs raw instead of guessing). + /// ticks). The ring's clock at connect must sit AFTER any ring-time we already know about, so a + /// candidate is plausible iff it falls in `[lowerBoundTicks, lowerBoundTicks + + /// syncTimeAnchorWindowTicks]`; exactly one must fit (ambiguity or no reference at all → nil → the + /// caller logs raw instead of guessing). + /// + /// `lowerBoundTicks` is any ring-time known to precede the ring's clock NOW: the persisted resume + /// cursor at connect, or — when that is 0 (fresh pair / post-reboot reset) or too stale — the largest + /// envelope ring-time the drain has actually seen (`OuraHistoryDrain.maxSeenRingTime`), which needs no + /// anchor to read and so breaks the cursor↔anchor deadlock (2026-09-02/03 captures). + /// /// Pure and testable; the honest-data invariant is "no anchor beats a wrong anchor". - public static func syncTimeAnchorCandidate(responseValue: UInt32, historyCursor: UInt32) -> UInt32? { - guard historyCursor > 0 else { return nil } - let lower = Int64(historyCursor) - let upper = lower + 6_048_000 // 7 days of 100 ms ticks + public static func syncTimeAnchorCandidate(responseValue: UInt32, lowerBoundTicks: UInt32) -> UInt32? { + guard lowerBoundTicks > 0 else { return nil } + let lower = Int64(lowerBoundTicks) + let upper = lower + syncTimeAnchorWindowTicks let readings = [Int64(responseValue), Int64(responseValue) * 10] let fits = readings.filter { $0 >= lower && $0 <= upper && $0 <= Int64(UInt32.max) } guard fits.count == 1 else { return nil } diff --git a/Packages/OuraProtocol/Tests/OuraProtocolTests/FramingTests.swift b/Packages/OuraProtocol/Tests/OuraProtocolTests/FramingTests.swift index d6858928e8..7187c5772f 100644 --- a/Packages/OuraProtocol/Tests/OuraProtocolTests/FramingTests.swift +++ b/Packages/OuraProtocol/Tests/OuraProtocolTests/FramingTests.swift @@ -219,22 +219,54 @@ final class FramingTests: XCTestCase { // MARK: - 0x13 -> anchor tick disambiguation (OuraDriver.syncTimeAnchorCandidate) func testSyncTimeAnchorCandidateResolvesUnit() { - // The 2026-07-13 shape: cursor banked at 4_413_933 (last night's log end); ~11 h later the - // ring's clock is ~4.81M ticks. A raw-ticks response fits the [cursor, cursor+7d] window and + // The 2026-07-13 shape: floor banked at 4_413_933 (last night's log end); ~11 h later the + // ring's clock is ~4.81M ticks. A raw-ticks response fits the [floor, floor+45d] window and // the seconds x10 reading does not -> unambiguous ticks. - XCTAssertEqual(OuraDriver.syncTimeAnchorCandidate(responseValue: 4_810_000, historyCursor: 4_413_933), + XCTAssertEqual(OuraDriver.syncTimeAnchorCandidate(responseValue: 4_810_000, lowerBoundTicks: 4_413_933), 4_810_000) // A seconds-unit response (481_000 s = 4.81M ticks) only fits when multiplied x10. - XCTAssertEqual(OuraDriver.syncTimeAnchorCandidate(responseValue: 481_000, historyCursor: 4_413_933), + XCTAssertEqual(OuraDriver.syncTimeAnchorCandidate(responseValue: 481_000, lowerBoundTicks: 4_413_933), 4_810_000) - // Below the cursor in both readings (ring reboot / stale value) -> nil. - XCTAssertNil(OuraDriver.syncTimeAnchorCandidate(responseValue: 100_000, historyCursor: 4_413_933)) - // Beyond cursor+7d in both readings -> nil. - XCTAssertNil(OuraDriver.syncTimeAnchorCandidate(responseValue: 40_000_000, historyCursor: 4_413_933)) - // A fresh/reset cursor gives no reference -> nil (never guess on a full pull). - XCTAssertNil(OuraDriver.syncTimeAnchorCandidate(responseValue: 4_810_000, historyCursor: 0)) - // Ambiguity guard: BOTH readings inside the window -> nil. - XCTAssertNil(OuraDriver.syncTimeAnchorCandidate(responseValue: 150_000, historyCursor: 140_000)) + // Below the floor in both readings (ring reboot / stale value) -> nil. + XCTAssertNil(OuraDriver.syncTimeAnchorCandidate(responseValue: 100_000, lowerBoundTicks: 4_413_933)) + // Beyond floor+45d in both readings -> nil. + XCTAssertNil(OuraDriver.syncTimeAnchorCandidate(responseValue: 50_000_000, lowerBoundTicks: 4_413_933)) + // No reference at all -> nil (never guess). + XCTAssertNil(OuraDriver.syncTimeAnchorCandidate(responseValue: 4_810_000, lowerBoundTicks: 0)) + // Ambiguity guard: BOTH readings inside the window -> nil. Reachable only while the floor is + // under window/9 (~5 days of ring clock), i.e. a barely-run ring. + XCTAssertNil(OuraDriver.syncTimeAnchorCandidate(responseValue: 150_000, lowerBoundTicks: 140_000)) + } + + /// Regression from the 2026-09-02/03 iOS captures. The ring's 0x13 reply reads 0x0218767f = + /// 35_157_631 ticks while the persisted resume cursor sits at 28_073_725 — 8.20 days behind. Under the + /// old 7-day window neither reading fit, so no anchor was adopted; with no anchor the drain-end commit + /// could not advance the cursor (`resumeCursorAtDrainEnd(resolvesUnderAnchor: false)` returns it + /// unchanged), so the cursor stayed stale and the gap only grew — a permanent loop, one full re-serve + /// of the same window per launch. The widened window resolves it, unambiguously. + func testSyncTimeAnchorCandidateAcceptsAStaleCursorFromTheCaptures() { + let staleCursor: UInt32 = 28_073_725 // banked 2026-08-26 02:49:56 by the 300 s guard + for reply: UInt32 in [0x0218767f, 0x02187ce8, 0x02189fce] { // the three 09-03 launches + XCTAssertEqual(OuraDriver.syncTimeAnchorCandidate(responseValue: reply, + lowerBoundTicks: staleCursor), + reply, "0x\(String(reply, radix: 16)) must resolve as raw ticks") + } + // The old 7-day window is what excluded it: 28_073_725 + 6_048_000 = 34_121_725 < 35_157_631. + XCTAssertGreaterThan(OuraDriver.syncTimeAnchorWindowTicks, 35_157_631 - Int64(staleCursor), + "the window must cover the observed 8.2-day staleness") + } + + /// The other half of the deadlock: on a fresh pair / post-reboot reset the cursor is 0, so it + /// can never be the reference. The drain's `maxSeenRingTime` can — it counts EVERY history record's + /// envelope time and needs no anchor to read — so the caller retries the parked reply against it. + func testSyncTimeAnchorCandidateResolvesAgainstSeenRingTimeWhenCursorIsZero() { + let reply: UInt32 = 0x0211dbd0 // 34_724_816 — the 2026-09-02 capture, cursor 0 + XCTAssertNil(OuraDriver.syncTimeAnchorCandidate(responseValue: reply, lowerBoundTicks: 0)) + // First batch of a full pull lands the ring's OLDEST banked record (~14 days back). + let oldestBanked: UInt32 = 34_724_816 - 12_096_000 + XCTAssertEqual(OuraDriver.syncTimeAnchorCandidate(responseValue: reply, + lowerBoundTicks: oldestBanked), + reply) } func testAdoptSyncTimeAnchorResolvesHistoryTimes() { diff --git a/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraFeatureStatusTests.swift b/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraFeatureStatusTests.swift index fbb046104b..4a0e4d9c41 100644 --- a/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraFeatureStatusTests.swift +++ b/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraFeatureStatusTests.swift @@ -42,14 +42,27 @@ final class OuraFeatureStatusTests: XCTestCase { func testSpO2AndStepsReadsSurfaceFeatureStatus() { let d = OuraDriver(ringGen: .gen3, authKey: key) - // SpO2 (0x04): subscription 0 → server-gated off. + // An all-zero reply. The comment here used to read the zeros as "server-gated off"; it describes + // the FIXTURE, not a ring's real answer, and #1629 showed a real ring answering otherwise. XCTAssertEqual(d.handleSecureFrame(OuraSecureFrame(subop: 0x21, subBody: [0x04, 0x00, 0x00, 0x00, 0x00])), .featureStatus(OuraFeatureStatus(feature: 0x04, mode: 0, status: 0, state: 0, subscription: 0))) - // real_steps (0x0b): subscription 0 → server-gated off. XCTAssertEqual(d.handleSecureFrame(OuraSecureFrame(subop: 0x21, subBody: [0x0B, 0x00, 0x00, 0x00, 0x00])), .featureStatus(OuraFeatureStatus(feature: 0x0B, mode: 0, status: 0, state: 0, subscription: 0))) } + /// #1629: the shape a REAL ring actually answers with. On-device captures read real_steps back as + /// `status=1` from both an authenticated Oura-app session and NOOP's own offline, unauthenticated + /// connection to the same ring. Until now the suite only ever exercised the all-zero reply, so the + /// one reading anybody has actually observed on hardware was the one nothing pinned. + /// + /// This asserts the DECODE only. `status=1` says the ring reports the feature enabled; it is not + /// evidence that the ring emits `0x7E`/`0x7F`, and nothing here should be read as claiming that. + func testAnEnabledRealStepsStatusDecodesAsObservedOnHardware() { + let d = OuraDriver(ringGen: .gen3, authKey: key) + XCTAssertEqual(d.handleSecureFrame(OuraSecureFrame(subop: 0x21, subBody: [0x0B, 0x00, 0x01, 0x00, 0x00])), + .featureStatus(OuraFeatureStatus(feature: 0x0B, mode: 0, status: 1, state: 0, subscription: 0))) + } + func testUndecodableDiagnosticReplyFallsBackToEnableAck() { // A too-short 0x21 (can't decode a feature) must not crash or mis-route; it stays `.enableAck` so // the triplet never stalls on a malformed reply. diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AdaptiveExpenditureEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AdaptiveExpenditureEngine.swift new file mode 100644 index 0000000000..0789991c4a --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AdaptiveExpenditureEngine.swift @@ -0,0 +1,170 @@ +import Foundation + +/// One day's inputs for the retrospective expenditure estimate. Both fields are optional because the two +/// series are independently sparse — a day with a weigh-in and no food log is common, and inventing a +/// value for the missing one is exactly what this engine must not do. +public struct AdaptiveExpenditureDay: Equatable, Sendable { + public let day: String // "yyyy-MM-dd", the app's day key + public let caloriesIn: Double? + public let weightKg: Double? + + public init(day: String, caloriesIn: Double? = nil, weightKg: Double? = nil) { + self.day = day; self.caloriesIn = caloriesIn; self.weightKg = weightKg + } +} + +/// How much the estimate below deserves to be trusted. Deliberately coarse — the inputs do not support +/// a percentage, and a number would imply a precision this method does not have. +public enum AdaptiveExpenditureConfidence: String, Equatable, Sendable { + case building, moderate, high +} + +/// A retrospective estimate of average daily energy expenditure, with an interval. +/// +/// Never a single number: the method's error is dominated by things this engine cannot see (hydration +/// swings, an under-logged weekend), so a bare figure would be the fabrication the rest of NOOP refuses +/// to make. The interval is the honest output and the caller should render it as one. +public struct AdaptiveExpenditureEstimate: Equatable, Sendable { + public let estimatedDailyKcal: Double + public let lowerKcal: Double + public let upperKcal: Double + public let meanIntakeKcal: Double + public let weightSlopeKgPerDay: Double + public let intakeDays: Int + public let weightReadings: Int + public let windowDays: Int + public let confidence: AdaptiveExpenditureConfidence + + public init(estimatedDailyKcal: Double, lowerKcal: Double, upperKcal: Double, meanIntakeKcal: Double, + weightSlopeKgPerDay: Double, intakeDays: Int, weightReadings: Int, windowDays: Int, + confidence: AdaptiveExpenditureConfidence) { + self.estimatedDailyKcal = estimatedDailyKcal; self.lowerKcal = lowerKcal; self.upperKcal = upperKcal + self.meanIntakeKcal = meanIntakeKcal; self.weightSlopeKgPerDay = weightSlopeKgPerDay + self.intakeDays = intakeDays; self.weightReadings = weightReadings; self.windowDays = windowDays + self.confidence = confidence + } +} + +/// Average daily expenditure inferred from logged intake and the weight trend (TDEE by energy balance). +/// +/// `expenditure = intake − change in stored energy`, with the conventional 7,700 kcal per kg of body mass. +/// The identity is exact; the inputs are not. Day-to-day weight is mostly water, and food logs are +/// under-reported by a wide and person-specific margin, so this is meaningful only over weeks and only as +/// a range. +/// +/// DELIBERATELY NOT AN INPUT TO ANYTHING. It never feeds Charge, the calorie card, or the workout +/// calorie estimate: those come from measured heart rate through Keytel, and quietly overwriting a +/// measurement with an inference from a food diary would be a strictly worse number wearing the same +/// label. This answers a question the user asks explicitly, and returns nil rather than guess. +/// +/// Kotlin twin: `AdaptiveExpenditureEngine`. +public enum AdaptiveExpenditureEngine { + /// Energy density of body-mass change. The textbook 7,700 kcal/kg (≈3,500 kcal/lb) figure, which + /// assumes the change is adipose; it over-states early loss, when a real share of it is glycogen and + /// its bound water. That bias is one reason the output is an interval. + public static let kcalPerKg = 7_700.0 + + /// Windows. Three weeks is the floor because a week of water retention can hide a 500 kcal/day gap, + /// and six weeks is the ceiling because beyond that the body's own expenditure has adapted and the + /// average stops describing today. + public static let minWindowDays = 21 + public static let maxWindowDays = 42 + + /// Coverage floors. Intake is the weak input, so it carries the strictest gate: a fortnight of logs + /// AND 70% of the window, which together reject the common "logged hard for five days" pattern that + /// would otherwise read as a huge deficit. + public static let minIntakeDays = 14 + public static let minIntakeCoverage = 0.70 + public static let minWeightReadings = 6 + + /// nil when the history cannot support an estimate — the normal answer for most installs, and the + /// point of the gates. `days` need not be sorted or contiguous. + public static func estimate(days: [AdaptiveExpenditureDay]) -> AdaptiveExpenditureEstimate? { + let ordered = days.sorted { $0.day < $1.day } + guard let first = ordered.first?.day, let last = ordered.last?.day, + let span = dayCount(from: first, to: last), span >= minWindowDays else { return nil } + let window = min(span, maxWindowDays) + // Keep the most RECENT `window` days: an adapted metabolism makes the tail the honest part. + let recent = ordered.filter { d in + guard let back = dayCount(from: d.day, to: last) else { return false } + // `dayCount` is INCLUSIVE — `dayCount(last, last)` is 1 — so the last `window` days are + // `back <= window`. A strict `<` silently drops the oldest day while still reporting the + // full `window`, which both loses data and understates coverage. + return back <= window + } + + let intake = recent.compactMap { $0.caloriesIn }.filter { $0 > 0 } + let weights = recent.compactMap { d -> (Int, Double)? in + guard let w = d.weightKg, w > 0, let i = dayCount(from: first, to: d.day) else { return nil } + return (i, w) + } + guard intake.count >= minIntakeDays, + min(1.0, Double(intake.count) / Double(window)) >= minIntakeCoverage, + Set(weights.map { $0.0 }).count >= minWeightReadings, + let slope = leastSquaresSlope(weights) else { return nil } + + let meanIntake = intake.reduce(0, +) / Double(intake.count) + // The identity. A RISING weight means intake exceeded expenditure, so the stored-energy term is + // subtracted — getting this sign backwards is the classic error and it is why the test pins both + // directions rather than only a deficit. + let estimate = meanIntake - slope * kcalPerKg + + // Interval. Half a kilo of water across the window is an everyday swing and translates directly + // into an apparent daily gap; the intake half widens as coverage falls, because the days someone + // fails to log are not a random sample of their eating. + let waterKcalPerDay = (0.5 * kcalPerKg) / Double(window) + // Clamped: `coverage` is "share of the window that was logged", so it cannot exceed 1. A caller + // that merged its two sparse series badly and passed a day twice would otherwise push it above 1, + // which SHRINKS the margin and RAISES the confidence — making the answer look more certain than + // its data, the one direction this engine must never err in. Clamping rather than de-duplicating + // on purpose: silently picking one of two conflicting values for a day would hide the caller's bug. + let coverage = min(1.0, Double(intake.count) / Double(window)) + let intakeUncertainty = meanIntake * 0.10 * (1.0 - coverage) + meanIntake * 0.05 + let margin = waterKcalPerDay + intakeUncertainty + + // DISTINCT days, not readings. Two weigh-ins on one morning are one day of evidence about the + // trend, and counting both would let a chatty scale — or a caller that passed a day twice — buy + // the same confidence as a fortnight of extra data. + let weightDays = Set(weights.map { $0.0 }).count + let confidence: AdaptiveExpenditureConfidence + if window >= 28 && coverage >= 0.90 && weightDays >= 12 { confidence = .high } + else if window >= 21 && coverage >= 0.80 { confidence = .moderate } + else { confidence = .building } + + return AdaptiveExpenditureEstimate( + estimatedDailyKcal: estimate, + lowerKcal: estimate - margin, upperKcal: estimate + margin, + meanIntakeKcal: meanIntake, weightSlopeKgPerDay: slope, + intakeDays: min(intake.count, window), weightReadings: weightDays, windowDays: window, + confidence: confidence) + } + + /// Ordinary least-squares slope in kg per DAY. nil when every reading shares one day, which would + /// divide by zero — a real case when a scale syncs several readings with one timestamp. + static func leastSquaresSlope(_ points: [(Int, Double)]) -> Double? { + let n = Double(points.count) + guard n >= 2 else { return nil } + let meanX = points.reduce(0.0) { $0 + Double($1.0) } / n + let meanY = points.reduce(0.0) { $0 + $1.1 } / n + var num = 0.0, den = 0.0 + for (x, y) in points { + let dx = Double(x) - meanX + num += dx * (y - meanY); den += dx * dx + } + guard den > 0 else { return nil } + return num / den + } + + /// Whole days between two "yyyy-MM-dd" keys, inclusive of the first. + /// + /// nil on an unparseable key. `AnalyticsEngine.dayStartUtcSeconds` is deliberately nil-tolerant and + /// returns 0 for one (so a bad key cannot take down a scoring pass), which is indistinguishable from + /// 1970 here — and a stray 0 would silently stretch the window by twenty thousand days. Treating a + /// zero as unparseable is the difference between refusing to answer and answering nonsense. + static func dayCount(from: String, to: String) -> Int? { + let a = AnalyticsEngine.dayStartUtcSeconds(from) + let b = AnalyticsEngine.dayStartUtcSeconds(to) + guard a > 0, b > 0 else { return nil } + return (b - a) / 86_400 + 1 + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AlarmReadback.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AlarmReadback.swift new file mode 100644 index 0000000000..42b760ed58 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AlarmReadback.swift @@ -0,0 +1,65 @@ +import Foundation + +/// Whether the alarm time a strap reports back can be compared with the one we sent (#1706). +/// +/// The two halves are persisted under flat, device-less keys — `alarm.lastArmSentEpoch` and +/// `alarm.lastReportedEpoch`. On an install with more than one strap registered they can therefore +/// describe DIFFERENT devices, and comparing them then produces a confident "strap didn't accept the +/// time" about a strap that was never asked. On Android the readback is written only on the WHOOP 4.0 +/// path, so a 5.0-active install could only ever be comparing across straps. +/// +/// That verdict is not cosmetic here: it also drives `alarm.rejectStreak`, which raises a warning in +/// SmartAlarmView at two. A cross-strap comparison could climb that streak forever. +/// +/// So attribution is required, not assumed: unless both halves are known to come from the same strap, +/// this refuses to judge. Refusing is the same stance `WindowedStreamPlan` takes — a diagnosis that +/// cannot be proven is worse than none, because it sends the reader after the wrong device. +/// +/// Twin of Kotlin `AlarmReadback`. +public enum AlarmReadback { + + /// Seconds of slack allowed between what we armed and what the strap reports back. + public static let toleranceS = 120 + + public enum Verdict: Equatable { + /// Same strap, and the readback agrees within `toleranceS`. + case matches + /// Same strap, and it does not. This is the only value that means the strap refused. + case mismatch + /// The two halves came from different straps. Nothing can be concluded about either. + case differentStrap + /// One or both halves predate device attribution, so they cannot be tied to a strap. + case unattributed + } + + public static func verdict( + sentEpoch: Int, + reportedEpoch: Int, + sentDeviceId: String?, + reportedDeviceId: String?, + toleranceS: Int = AlarmReadback.toleranceS + ) -> Verdict { + guard let sentDeviceId, !sentDeviceId.isEmpty, + let reportedDeviceId, !reportedDeviceId.isEmpty else { return .unattributed } + guard sentDeviceId == reportedDeviceId else { return .differentStrap } + return abs(reportedEpoch - sentEpoch) > toleranceS ? .mismatch : .matches + } + + /// The suffix the debug export appends after the reported time. Byte-identical to the Kotlin twin. + public static func suffix(_ verdict: Verdict) -> String { + switch verdict { + case .matches: return " ✓ matches" + case .mismatch: return " ⚠️ MISMATCH — strap didn't accept the time" + case .differentStrap: return " (readback is from a different strap — not comparable)" + case .unattributed: return " (no strap recorded for one of these — not comparable)" + } + } + + /// Whether this verdict may advance the consecutive-rejection streak. Only a proven same-strap + /// disagreement counts: an unattributed or cross-strap reading must leave the streak untouched + /// rather than reset it, since neither is evidence either way. + public static func countsAsRejection(_ verdict: Verdict) -> Bool { verdict == .mismatch } + + /// Whether this verdict is evidence the strap DID accept, which clears the streak. + public static func clearsRejectionStreak(_ verdict: Verdict) -> Bool { verdict == .matches } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift index b7026d65c9..15edf923f9 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift @@ -175,6 +175,13 @@ public enum AnalyticsEngine { /// from `dayString`), and the Kotlin mirror degrades the SAME way (`runCatching { … }.getOrDefault(0)`) /// instead of throwing, so a single bad day key can never take down a whole scoring pass on either /// platform (nil-tolerant over fail-fast, per the #996 review). + /// + /// This is a UTC midnight that callers pair with one captured offset and fixed 86,400-second blocks, + /// so every boundary beyond a clock change sits an hour from the local midnight it names. + /// `LocalDayWindows` is the zone-rule-correct primitive built to replace that, and is deliberately + /// called by nothing yet. This function remains the shipped answer until a switch-over lands; the two + /// disagree by an hour on the far side of a transition, and `LocalDayWindowsTests` pins both answers + /// so the difference is visible rather than discovered. static func dayStartUtcSeconds(_ day: String) -> Int { Int(isoDay.date(from: day)?.timeIntervalSince1970 ?? 0) } @@ -293,6 +300,10 @@ public enum AnalyticsEngine { public static let vendorRespMinSpanS = 3_600 public static func analyzeDay(day: String, + // Optional sink for the Effort funnel line. Nil (the default) builds + // nothing at all — see StrainScorer.strain. A parameter rather than a + // field so this engine stays pure. + strainDiag: ((String) -> Void)? = nil, hr: [HRSample] = [], rr: [RRInterval] = [], resp: [RespSample] = [], @@ -502,11 +513,20 @@ public enum AnalyticsEngine { } else { let rrSorted = rr.sortedByTsStable() let enrichedProvided: [SleepSession] = providedSleep.map { s in + // #1884: no HR-only special case any more. This used to short-circuit on `s.hrOnly` to + // preserve #1801's display-only guarantee, which withheld both values. Now that an HR-only + // session reports what it measured, that clause is not merely redundant — an HR-only night + // that measured a resting HR but no HRV (no R-R banked) would take the short-circuit and + // skip the fill every other session gets. The rule is uniform: fill what is missing. guard s.restingHR == nil || s.avgHRV == nil else { return s } let rhr = s.restingHR ?? SleepStager.sessionRestingHR(start: s.start, end: s.end, hr: hr) let hrv = s.avgHRV ?? SleepStager.sessionAvgHRV(start: s.start, end: s.end, rr: rrSorted) + // `hrOnly` carried explicitly: unlike Kotlin's `copy`, this rebuilds the struct field by + // field, so a new flag is dropped by DEFAULT unless named here. #1884 removed the guard + // that used to keep HR-only nights away from this line, so this is now the only thing + // standing between the flag and silent loss rather than a belt. return SleepSession(start: s.start, end: s.end, efficiency: s.efficiency, - stages: s.stages, restingHR: rhr, avgHRV: hrv) + stages: s.stages, restingHR: rhr, avgHRV: hrv, hrOnly: s.hrOnly) } let keptDetected = refinedSessions.filter { d in !enrichedProvided.contains { $0.start < d.end && d.start < $0.end } @@ -546,17 +566,36 @@ public enum AnalyticsEngine { var deepS = 0.0, remS = 0.0, lightS = 0.0, tstS = 0.0 var inBedS = 0.0, effWeighted = 0.0 var disturbances = 0 + // Hypnogram COVERAGE across the group: how much of the fragments' own spans the stage segments + // actually account for. Accumulated separately from `inBedS` because that one later absorbs the + // inter-fragment gap (#777/#705), which is a different quantity — a bridged out-of-bed gap is + // known-awake time between two fragments, whereas a hole INSIDE a fragment is time we never + // observed at all. Summed straight off `s.stages` (no JSON re-parse: the segments are already + // decoded here), then handed to `HypnogramCoverage` so the ratio/clamp/nil rules have ONE + // definition shared with the merge-side guard. + // NOTE on scope at THIS call site: coverage here is summed off the DECODED segments, not off + // `stagesJSON`, so the timestamp-free shapes are not screened out by the payload-shape rule the + // merge side uses. A minute-dict session decodes to zero segments and contributes span with no + // cover, and what keeps it from reading as a holed night is `fraction`'s `coveredSeconds > 0` + // returning nil for a group with no timestamped stages at all. Same outcome, different + // mechanism — and it holds only while a group is single-sourced, which the day merge ensures by + // choosing one side. A group mixing a timestamped fragment with a minute-dict one would read as + // holed; `HypnogramCoverageTests.testMixedSourceGroupReadsAsHoled` pins both halves. + var coveredS = 0.0, spanS = 0.0 for s in mainGroup { let m = SleepStager.hypnogramMetrics(s) let inBed = Double(s.end - s.start) inBedS += inBed // each fragment's own in-bed span (the gap is added below) effWeighted += s.efficiency * inBed // in-bed-weighted efficiency across the group + spanS += inBed + for seg in s.stages where seg.end > seg.start { coveredS += Double(seg.end - seg.start) } deepS += m.deepMin * 60.0 remS += m.remMin * 60.0 lightS += m.lightMin * 60.0 tstS += m.tstS disturbances += m.disturbances } + let stageCoverage = HypnogramCoverage.fraction(coveredSeconds: coveredS, spanSeconds: spanS) // OUT-OF-BED time BETWEEN bridged fragments is AWAKE (#777/#705): a main night bridged from two // fragments split by a 20-min wake gap was reporting that gap as nowhere (it is in no fragment's // [start,end) span), so 20+ min of real awake read as ~4 min - a v7.1 regression, multi-reporter. @@ -642,7 +681,25 @@ public enum AnalyticsEngine { // negligible shift. The Rest/sleep-quality term is main-night; the recovery physiology is // day-best-resting, night-dominated. Keep these two definitions distinct on purpose. // Daily resting HR = lowest per-session resting HR across matched sessions. - let restingHRDaily = matched.compactMap { $0.restingHR }.min() + // #1801/#1884: the sessions whose PHYSIOLOGY is folded into the day's aggregates. Motion-backed + // sessions are PREFERRED; an HR-only night is used only when the day has no other kind. + // + // #1801 excluded HR-only nights outright, reasoning that a baseline is the one thing a false positive + // cannot be unwound from. #1884 narrowed that rather than reversing it: only the session BOUNDS are + // inferred from heart rate — each RMSSD is measured over its own 5-minute window — so excluding the + // night discarded a real 22-25ms HRV and left Charge with NO input instead of a slightly fuzzy one, on + // every scoring pass in the field log. Preferring keeps the original protection exactly where it earned its keep (a mixed + // day still ignores the HR-only night outright) and gives it up only where the alternative was + // nothing at all. The night still travels marked `hrOnly` for any consumer that wants to weigh it + // down; what it no longer gets is a silent delete. + // + // Named once rather than filtered at each use: the deep-window HRV pool and the SDNN index below + // re-derive from `rr` over each session's own stages instead of reading restingHR/avgHRV, so the + // only way to scope them is through the session set itself — which is precisely the "one forgotten + // call site" a scattered filter invites. + let physiologyOnly = matched.filter { !$0.hrOnly } + let physiologySessions = physiologyOnly.isEmpty ? matched : physiologyOnly + let restingHRDaily = physiologySessions.compactMap { $0.restingHR }.min() // Daily avg HRV = in-bed-weighted mean of per-session avg HRV. let avgHRVDaily: Double? = { if deepHrvWindow { @@ -652,13 +709,13 @@ public enum AnalyticsEngine { // = successive diffs). nil when no deep sleep is detected (WHOOP-4.0 staging can be sparse) — // the caller shows calibrating, never a fabricated number. let rrSorted = rr.sortedByTsStable() - let deep = matched.flatMap { s in + let deep = physiologySessions.flatMap { s in SleepStager.sessionHrvWindows(start: s.start, end: s.end, rr: rrSorted, stages: s.stages) .filter { $0.stage == "deep" }.compactMap { $0.rmssd } } return deep.isEmpty ? nil : deep.reduce(0, +) / Double(deep.count) } - let pairs = matched.compactMap { s -> (Double, Double)? in + let pairs = physiologySessions.compactMap { s -> (Double, Double)? in s.avgHRV.map { ($0, Double(s.end - s.start)) } } guard !pairs.isEmpty else { return nil } @@ -675,7 +732,7 @@ public enum AnalyticsEngine { // against a watch meaningless. The 5-min index is window-comparable to those. nil when no segment has // enough clean beats (HRVAnalyzer's own gate). Keeps the R-R timestamps (segmentation needs them). let avgSDNNDaily: Double? = { - let inBed = rr.filter { r in matched.contains { r.ts >= $0.start && r.ts < $0.end } } + let inBed = rr.filter { r in physiologySessions.contains { r.ts >= $0.start && r.ts < $0.end } } return inBed.isEmpty ? nil : HRVAnalyzer.sdnnIndex(inBed, segmentSec: 300) }() @@ -801,8 +858,12 @@ public enum AnalyticsEngine { // night `hr` for pure-function callers/tests. let effMaxHR: Double? = maxHROverride ?? (profile.age > 0 ? StrainScorer.tanakaHRmax(age: profile.age) : nil) let restForStrain = restingHRDaily.map(Double.init) ?? StrainScorer.defaultRestingHR + // The Effort ring's own funnel. A nil sink builds nothing at all, so a caller that does not want + // diagnostics pays nothing; IntelligenceEngine passes its per-day recorder, the same one the + // `workout detect` and `sleep-detect` lines beside it already use. let strain = StrainScorer.strain(dayHr ?? hr, maxHR: effMaxHR, restingHR: restForStrain, - method: effortMethod, sex: profile.sex) + method: effortMethod, sex: profile.sex, + diag: strainDiag, day: day) // ── Workouts ────────────────────────────────────────────────────────── // Detect over the full CALENDAR day (dayHr/dayGravity) when the caller supplies it, so a @@ -904,7 +965,17 @@ public enum AnalyticsEngine { // The ABSOLUTE this pass's deviation was derived from (#1636). Set HERE, beside // `skinTempDevC`, so the engine's own row is symmetric: any path that persists this // struct directly keeps both thermal values, not just the one. - skinTempC: nightlySkinTempC) + skinTempC: nightlySkinTempC, + // "Every session this day was staged from heart rate alone." + // + // #1884: read from the SESSIONS' own marker, NOT from `physiologySessions.isEmpty`. Those two + // were equivalent while the set was `matched` minus the HR-only ones, so an all-HR-only night + // emptied it. They are NOT equivalent now that the set FALLS BACK to `matched`: it can never + // be empty when `matched` is not, which would have pinned this flag to false forever and + // silently retired the #1879 note. Deriving it from `hrOnly` states what the flag has always + // meant and is independent of how the physiology set is chosen. Byte-identical twin of Kotlin + // `AnalyticsEngine`'s `sleepHrOnly = if (matched.isEmpty()) null else matched.all { it.hrOnly }`. + sleepHrOnly: matched.isEmpty ? nil : matched.allSatisfy { $0.hrOnly }) _ = sleepStart; _ = sleepEnd // available for callers wiring sleep_start/end columns // ── Cache rows ──────────────────────────────────────────────────────── @@ -952,11 +1023,14 @@ public enum AnalyticsEngine { let effortConfidence = ScoreConfidence.effort(strain: strain, hrSampleCount: hr.count) // Rest confidence with H9: downgrade a high-efficiency night whose deep+REM share is implausibly low // to low-confidence (likely staging miss) — honest, no faked stages. tstS/efficiency are the - // main-group totals computed above; restorative = deepS + remS. + // main-group totals computed above; restorative = deepS + remS. `stageCoverage` adds the third + // guard: a night whose stage timeline covers only part of its own span (an incompletely-received + // device hypnogram) cannot earn a SOLID Rest either, and neither of the other two can see it. let restConfidence = ScoreConfidence.rest(hasSession: !matched.isEmpty, hasStagedSleep: hasStagedSleep, asleepSeconds: tstS, restorativeSeconds: deepS + remS, - efficiency: efficiency, gravitySparse: gravitySparse) + efficiency: efficiency, gravitySparse: gravitySparse, + stageCoverage: stageCoverage) return DayResult(daily: daily, sleepSessions: matched, cachedSleep: cachedSleep, workouts: workouts, recovery: recovery, strain: strain, diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyzeRecentDayCache.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyzeRecentDayCache.swift index 8654ee8bd1..58f9c45b28 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyzeRecentDayCache.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyzeRecentDayCache.swift @@ -31,11 +31,21 @@ public enum AnalyzeRecentDayCache { /// both a 4.0 and a 5/MG): when a day's resolved owner flips between straps, keying on the owner makes /// the reuse invalidate **explicitly**, rather than relying on two different devices never producing an /// identical `count`+`maxTs` for the same window. + /// - `streams`: the opaque per-day witness of every OTHER scored stream — PPG-derived HR, R-R, + /// respiration, SpO2, gravity, steps, skin temp and events (`WhoopStore.dayStreamFingerprint`). + /// #29: a history offload does not commit its channels together, and an offloaded HR row duplicating + /// a live one is dropped by `ON CONFLICT DO NOTHING`, so a night can be scored from HR alone and then + /// gain its R-R with `hrCount`/`hrMaxTs` completely unmoved. Keyed on HR alone this said "reuse", and + /// the HRV-less scan was re-served for the rest of the session — a force refresh included, since + /// `force` only bypasses the whole-pass watermark gate. The whole-pass gate had already been widened + /// to every stream (`analysisFingerprint`, v2); this is the same widening at day granularity, which is + /// where the reuse decision is actually made. /// /// Inputs that feed `analyzeDay` but are pass-global rather than per-day (profile, baselines1, sleep /// need / consistency, habitual midsleep, tz, stager toggles) are NOT in this key — the engine drops the /// whole cache when its pass config signature changes, which covers them. public static func cacheKey(owner: String, hrCount: Int, hrMaxTs: Int, skinAnchorRaw: Double?, + streams: String, // #1575: whether this day is the one that emits the PER-WINDOW HRV detail // (`dayStart == nowLocalMidnight`). Now that an active trace no longer // disables reuse, this has to invalidate: the night cached as "today" with @@ -47,6 +57,6 @@ public enum AnalyzeRecentDayCache { // a trace mode is on. hrvWindowDetail: Bool) -> String { let anchor = skinAnchorRaw.map { String($0.bitPattern) } ?? "nil" - return "\(owner)|\(hrCount):\(hrMaxTs):\(anchor):\(hrvWindowDetail ? "d" : "s")" + return "\(owner)|\(hrCount):\(hrMaxTs):\(anchor):\(hrvWindowDetail ? "d" : "s")|\(streams)" } } diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/Baselines.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/Baselines.swift index d88027646f..ff4fa689cd 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/Baselines.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/Baselines.swift @@ -279,6 +279,29 @@ public enum Baselines { /// from your strap for N days" when the baseline aged out silently instead of only "building your /// baseline". Pure and TZ-free (civil-day arithmetic); mirror EXACTLY in the Kotlin twin. /// `dayKeys`/`nightlyHrv` are parallel (same night per index); `today` is an ISO `yyyy-MM-dd` key. + /// Nights the app OBSERVED in the recent window, and how many of them carried no usable HRV. + /// + /// Distinct from both neighbours, and the gap between them. `calibrationNights` counts progress + /// toward a baseline; `nightsSinceNewestValidNight` measures a TOTAL drought and only speaks once it + /// passes `staleDays`. Neither says anything about the common case: nights arriving, but most of them + /// carrying nothing. A wearer five days in with three empty nights sits at "2 of 4" with no reason + /// given, which reads as a stuck counter rather than as missing data — that is the report this exists + /// to answer. + /// + /// Counts only days the app has a row for, so a new install is not charged for nights before it owned + /// the strap. Pure and TZ-free (civil-day arithmetic); mirror EXACTLY in the Kotlin twin. + public static func recentHrvCoverage(dayKeys: [String], nightlyHrv: [Double?], today: String, + window: Int = staleDays) -> (observed: Int, missing: Int) { + guard let t = isoEpochDay(today) else { return (0, 0) } + var observed = 0, missing = 0 + for i in 0..= 0, t - d < window else { continue } + observed += 1 + if nightlyHrv[i] == nil { missing += 1 } + } + return (observed, missing) + } + public static func nightsSinceNewestValidNight(dayKeys: [String], nightlyHrv: [Double?], today: String) -> Int? { var newest: String? = nil for i in 0.., + controlDays: Set, outcomeByDay: [String: Double], behavior: String, outcome: String) -> BehaviorEffect? { @@ -91,7 +105,10 @@ public enum BehaviorInsights { var withoutVals: [Double] = [] for (day, value) in outcomeByDay { if behaviorDays.contains(day) { withVals.append(value) } - else { withoutVals.append(value) } + else if controlDays.contains(day) { withoutVals.append(value) } + // Neither: the behaviour was not logged that day. Not a control — no information. + // A day in BOTH would count as "with"; callers merge imported ∪ native before building + // these, so one question cannot hold two answers for one day. } let n1 = withVals.count @@ -125,11 +142,16 @@ public enum BehaviorInsights { /// return them sorted by |cohensD| descending, with significant effects first. /// Behaviors that don't yield a computable effect are dropped. public static func rank(behaviors: [String: Set], + controls: [String: Set], outcomeByDay: [String: Double], outcome: String) -> [BehaviorEffect] { var effects: [BehaviorEffect] = [] for (name, days) in behaviors { - if let e = effect(behaviorDays: days, outcomeByDay: outcomeByDay, + // A behaviour absent from `controls` has no controls and yields nothing — failing CLOSED, so + // a caller that forgets loses the insight rather than getting a wrong one measured against + // every day the user never opened the journal. + if let e = effect(behaviorDays: days, controlDays: controls[name] ?? [], + outcomeByDay: outcomeByDay, behavior: name, outcome: outcome) { effects.append(e) } diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/CaptureCompleteness.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/CaptureCompleteness.swift index 2f97eb368f..350874cd73 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/CaptureCompleteness.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/CaptureCompleteness.swift @@ -72,7 +72,10 @@ public enum CaptureCompleteness { .dataImport: ["import stage=", "rowsIn="], .steps: ["stepsRaw", "stepsCal"], .battery: ["bank soc=", "socSeries"], - .recovery: ["charge term", "charge score=", "charge nilScore"], + // "charge day=" for the same reason as the Kotlin twin: IntelligenceEngine re-emits every + // recovery trace line as `charge day= ` + the body, so none of the three variants this + // used to name could ever match and the domain reported MISSING on every capture. + .recovery: ["charge day="], .hrv: ["hrv rmssd=", "hrv result=", "hrv nightSummary"], .universal: ["dayOwner ", "strapClock "], ] diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/CircadianEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/CircadianEngine.swift index f6278a2388..1e9e90e450 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/CircadianEngine.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/CircadianEngine.swift @@ -34,12 +34,28 @@ public enum CircadianEngine { /// so a low-resting wearer faces a LOWER absolute bar, not a higher one — the opposite of the concern /// raised in #982, which assumed the gate penalises the fittest. /// - /// Deliberately NOT re-tuned to an absolute bpm floor. The shape is arguably wrong for this domain, - /// but every candidate value is unvalidated, nobody is currently silenced by it, and the direction of - /// the harm is not established (it needs a wearer whose amplitude is disproportionately small for - /// their mesor — an n=1 observation). `CircadianEngineTests` pins what the gate costs at each mesor so - /// the trade is visible to whoever does have the data to change it. + /// The VALUE is still not re-tuned — every candidate remains unvalidated. What changed is the SHAPE: + /// `minAbsoluteAmplitudeBpm` now passes a swing large enough in bpm regardless of mesor. This note + /// asked for "a wearer whose amplitude is disproportionately small for their mesor — an n=1 + /// observation" before that could happen, and one arrived: 5.5 bpm on a 74.7 bpm mesor with a coherent + /// acrophase. "Nobody is currently silenced by it" was true when written and is no longer. + /// `CircadianEngineTests` pins what the gate costs at each mesor so the trade stays visible. public static let minRelativeAmplitude: Double = 0.10 + /// Absolute amplitude (bpm) that reads as rhythmic whatever the mesor — the relative bar's escape hatch. + /// + /// The relative test scales the requirement WITH resting HR, so the identical swing is accepted on one + /// body and refused on another: 5.5 bpm passes at a 55 bpm mesor and fails at 74.7. That is not a + /// judgement about the rhythm, it is a judgement about the baseline, and a measured wearer sat exactly + /// there — 5.5 bpm on a 74.7 bpm mesor, acrophase 16.1 h implying a CBTmin near 04:06, which is a + /// textbook phase rather than noise. `minRelativeAmplitude`'s own note called for precisely that + /// observation ("a wearer whose amplitude is disproportionately small for their mesor") before the + /// shape could be revisited. + /// + /// 4.5 bpm is NOT a new tuning constant: it is the absolute amplitude the relative gate ALREADY + /// accepts at the bottom of the ~45-75 bpm mesor range it was described against. The rule this + /// encodes is internal consistency — an amplitude good enough for some wearer is good enough for all + /// — so the change is strictly more permissive and no one who reads rhythmic today stops. + public static let minAbsoluteAmplitudeBpm: Double = 4.5 /// Max clock-shift the planner steps per day (hours) — the well-established ~1 h/day re-entrainment rate. public static let maxShiftPerDayHours: Double = 1.0 /// CBTmin sits roughly this many hours before habitual wake; used to translate the activity acrophase @@ -48,6 +64,20 @@ public enum CircadianEngine { /// Activity acrophase (peak activity) sits roughly this many hours after CBTmin in a typical day — the /// offset used to convert the cosinor acrophase into an estimated temperature-minimum time. public static let acrophaseAfterCbtMinHours: Double = 12.0 + /// Population-typical wake hour, used ONLY to place the chronotype reference point. + /// + /// `offsetVsScheduleMinutes` compares the clock to the USER'S OWN schedule, so it cannot name a + /// chronotype: someone reliably asleep 03:00-11:00 has an offset near zero and would read + /// "intermediate" while being strongly evening-type. A named lean needs an ABSOLUTE phase, so it is + /// bucketed from `tempMinHour` against a population reference instead. + public static let chronotypeReferenceWakeHour: Double = 7.0 + /// Half-width of the "intermediate" band around the reference CBTmin, in hours. + /// + /// Deliberately wide. `tempMinHour` is derived from an activity cosinor (`acrophase - 12 h`) unless a + /// measured `observedTempMinHour` is supplied, and NO production caller supplies one today — so this + /// is a lean inferred from movement, not a thermal measurement. A one-hour band either side keeps the + /// three buckets coarse enough to be honest about that. + public static let chronotypeBandHours: Double = 1.0 // MARK: - Inputs @@ -180,7 +210,10 @@ public enum CircadianEngine { guard let fit = cosinor(bins) else { return nil } let relativeAmplitude = fit.mesor != 0 ? fit.amplitude / abs(fit.mesor) : 0 - if daysObserved < minDaysForFit || relativeAmplitude < minRelativeAmplitude { + // Rhythmic on EITHER measure: a proportional swing, or an absolute one large enough to read on + // any baseline. See `minAbsoluteAmplitudeBpm` for why the relative test alone was self-inconsistent. + let rhythmic = relativeAmplitude >= minRelativeAmplitude || fit.amplitude >= minAbsoluteAmplitudeBpm + if daysObserved < minDaysForFit || !rhythmic { // A reading is returned, but flagged unreadable so the surface says "hard to read right now." let tmin = observedTempMinHour ?? wrap24(fit.acrophaseHours - acrophaseAfterCbtMinHours) return PhaseEstimate(tempMinHour: tmin, acrophaseHours: fit.acrophaseHours, @@ -198,7 +231,13 @@ public enum CircadianEngine { let offsetHours = signedHourDelta(from: idealTempMin, to: tempMinHour) let offsetMinutes = offsetHours * 60.0 - let confidence: PhaseConfidence = daysObserved >= goodDaysForFit ? .solid : .wide + // SOLID means strong on BOTH axes, not just enough days. A rhythm admitted by + // `minAbsoluteAmplitudeBpm` alone is real but modest, and a smaller swing pins its acrophase less + // tightly — so it stays `.wide`. That matters because `.wide` is what withholds `chronotype`, + // which names a category off exactly that acrophase: widening the readable gate should give more + // people a body clock, not give a thinner fit a firmer label. + let confidence: PhaseConfidence = + (daysObserved >= goodDaysForFit && relativeAmplitude >= minRelativeAmplitude) ? .solid : .wide let lean: String if offsetMinutes > 20 { lean = "later (a night-owl lean)" } else if offsetMinutes < -20 { lean = "earlier (a morning-lark lean)" } @@ -322,6 +361,86 @@ public enum CircadianEngine { return x } + /// A coarse, ABSOLUTE body-clock category: where the temperature minimum sits on the clock. + /// + /// NOT the "chronotype lean" wording used elsewhere. `estimatePhase` builds a `lean` string from + /// `offsetVsScheduleMinutes` ("a night-owl lean"), and the v5 skin-temp design spec defines chronotype + /// lean as "earlier/later than your sleep schedule implies" — both RELATIVE to the wearer's own + /// schedule. This is relative to the CLOCK, and the two genuinely disagree: a consistent 03:00-11:00 + /// sleeper is well-aligned by the relative read and `.evening` by this one. Keep the vocabularies + /// disjoint in anything user-facing, or the two readings look like a contradiction (#1409). + /// + /// Three buckets, not a score: the underlying phase estimate is an activity fit, and a finer grain + /// would imply precision it does not have. + public enum Chronotype: String, Equatable, Sendable, Codable { + case morning // CBTmin earlier than the population reference + case intermediate + case evening // CBTmin later than the population reference + } + + /// The reference CBTmin clock hour a lean is measured against: the population wake hour minus the + /// same `cbtMinBeforeWakeHours` the phase estimator already uses, so the anchor moves with the + /// engine's own model rather than being a second, independently-drifting constant. + public static var chronotypeAnchorHour: Double { + wrap24(chronotypeReferenceWakeHour - cbtMinBeforeWakeHours) + } + + /// The chronotype-ideal sleep window for a night of `durationHours`, as clock hours. + /// + /// Anchored on the temperature minimum: a well-entrained sleeper wakes about `cbtMinBeforeWakeHours` + /// after CBTmin, so the ideal wake is `tempMinHour + cbtMinBeforeWakeHours` and the ideal bedtime is + /// that minus the night's own length. + /// + /// USING THE ACTUAL DURATION IS THE POINT. Giving the ideal arc the same length as the real one makes + /// the comparison purely about PHASE — did you sleep at the right TIME — so a short night reads as + /// aligned-but-short rather than as misaligned. Feeding a "needed" duration instead would fold two + /// different failures into one arc and make a debt look like a body-clock problem. + /// + /// nil for a non-positive or impossible duration; a window longer than a day cannot be placed on a + /// 24 h ring, and silently wrapping it would draw a full circle that means nothing. + public static func idealSleepWindow(tempMinHour: Double, + durationHours: Double) -> (bedHour: Double, wakeHour: Double)? { + guard durationHours > 0, durationHours < 24 else { return nil } + let wake = wrap24(tempMinHour + cbtMinBeforeWakeHours) + return (bedHour: wrap24(wake - durationHours), wakeHour: wake) + } + + /// Signed hours the ACTUAL sleep window sits later (+) or earlier (−) than the chronotype-ideal one. + /// + /// Deliberately NOT `offsetVsScheduleMinutes`. That field compares the body clock to the wearer's own + /// SCHEDULE; this compares the night actually slept to where the CLOCK wanted it. A dial that draws an + /// actual arc against an ideal arc must caption itself with the distance between those two arcs, or + /// the number contradicts the picture — the two disagree exactly when someone keeps a consistent + /// schedule that does not suit their clock, which is the case the dial exists to show. + /// + /// Anchored on wake rather than bedtime because `idealSleepWindow` builds the ideal window from the + /// wake end; comparing bedtimes would fold the night's DURATION into a phase reading. + public static func sleepWindowOffsetHours(tempMinHour: Double, actualWakeHour: Double) -> Double { + signedHourDelta(from: wrap24(tempMinHour + cbtMinBeforeWakeHours), to: wrap24(actualWakeHour)) + } + + /// Bucket an ABSOLUTE temperature-minimum clock hour into a lean. + /// + /// Compared CIRCULARLY. A `tempMinHour` of 23:30 is five hours BEFORE the 04:30 anchor — a strong + /// morning lean — and a naive `23.5 > 5.5` would call it evening instead. Pure, so the boundaries are + /// assertable without building a fit. + public static func chronotype(tempMinHour: Double) -> Chronotype { + let delta = signedHourDelta(from: chronotypeAnchorHour, to: wrap24(tempMinHour)) + if delta < -chronotypeBandHours { return .morning } + if delta > chronotypeBandHours { return .evening } + return .intermediate + } + + /// The lean for a phase estimate, or nil when the fit is not strong enough to name one. + /// + /// Gated to `.solid` on purpose. `.wide` is a real fit on thin data and is fine for the continuous + /// offset the card already shows, but a NAMED category reads as a fact about the person rather than a + /// reading of the week, so it waits for the stronger tier. `.unreadable` never names one. + public static func chronotype(_ estimate: PhaseEstimate) -> Chronotype? { + guard estimate.confidence == .solid else { return nil } + return chronotype(tempMinHour: estimate.tempMinHour) + } + /// Signed shortest delta in hours from `a` to `b` on the 24 h clock, in (−12, 12]. static func signedHourDelta(from a: Double, to b: Double) -> Double { var d = (b - a).truncatingRemainder(dividingBy: 24.0) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/ClockFormat.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/ClockFormat.swift new file mode 100644 index 0000000000..07e1ef1457 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/ClockFormat.swift @@ -0,0 +1,71 @@ +import Foundation + +/// #1821: which clock the UI shows times in. Twin of the Kotlin `ClockFormatPreference`. +/// +/// NOOP had no such setting: every user-facing time came from the device REGION's convention. On Apple +/// `AppLanguage.activeLocale` builds `language_REGION` deliberately, to keep regional date order and +/// clock style - but constructing a locale from a region identifier DISCARDS the user's explicit +/// "24-Hour Time" switch, because that override lives on `Locale.autoupdatingCurrent`, not on the region +/// default. A reader in a 24-hour region who prefers 12-hour had no way to say so, which is the report +/// this exists to answer. +public enum ClockFormatPreference: String, CaseIterable, Sendable { + /// Follow the device's own clock switch. The default, so nobody's display changes on upgrade. + case system + case twelveHour + case twentyFourHour + + /// Persistence key, shared by the Apple `@AppStorage` binding and the Android preference so the two + /// platforms cannot drift apart on the name of the thing they store. + public static let defaultsKey = "clockFormatPreference" + + /// Unknown/absent stored values fall back to `.system` rather than to a hardcoded clock: a value we + /// cannot parse must not silently pin every reader to one format. + public static func from(stored: String?) -> ClockFormatPreference { + guard let stored, let parsed = ClockFormatPreference(rawValue: stored) else { return .system } + return parsed + } +} + +public enum ClockFormat { + /// Resolve the preference against what the device reports. `systemUses24Hour` is injected rather than + /// read here so this stays pure and identically testable on both platforms - Apple derives it from + /// `Locale.autoupdatingCurrent` (which DOES carry the 24-Hour Time switch), Android from + /// `DateFormat.is24HourFormat(context)`. + public static func uses24Hour(preference: ClockFormatPreference, systemUses24Hour: Bool) -> Bool { + switch preference { + case .system: return systemUses24Hour + case .twelveHour: return false + case .twentyFourHour: return true + } + } + + /// Literal wall-clock pattern at minute precision, for formatters driven by an explicit pattern. + /// Used by Android, whose existing time labels already build `SimpleDateFormat` from a literal. + public static func hourMinutePattern(uses24Hour: Bool) -> String { + uses24Hour ? "HH:mm" : "h:mm a" + } + + /// A locale identifier carrying an explicit HOUR CYCLE, so a formatter driven by `timeStyle` or by a + /// `j`-bearing template resolves the clock the reader chose instead of the region default. + /// + /// This is what lets the setting reach the ~17 Apple formatters that never mention "HH:mm" at all - + /// they say `timeStyle = .short` or template `"jmm"`, and both ask the LOCALE for the hour. Rewriting + /// each into an explicit pattern would have thrown away localized date order, separators and AM/PM + /// wording; setting the hour cycle keeps all of it and changes only the part the reader asked for. + /// + /// `h23`/`h12` are the ICU `hours` keyword values. A malformed identifier would silently fall back to + /// the base locale - a no-op that looks like a working setting - so `ClockFormatLocaleTests` asserts + /// against a real `DateFormatter` on macOS rather than trusting the string. + public static func hourCycleLocaleIdentifier(base: String, uses24Hour: Bool) -> String { + "\(base)@hours=\(uses24Hour ? "h23" : "h12")" + } + + /// SKELETON template for the same time, for `setLocalizedDateFormatFromTemplate`. Apple uses this + /// rather than the literal above so the locale still decides ordering, separator and AM/PM wording, + /// while the H-vs-h choice - the only part the reader asked to control - stays ours. Note this is + /// deliberately NOT the `j` template the buggy path used: `j` resolves the hour from the locale, which + /// is exactly how a reader's explicit preference got discarded. + public static func hourMinuteTemplate(uses24Hour: Bool) -> String { + uses24Hour ? "Hmm" : "hmm" + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/CoachSuggestions.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/CoachSuggestions.swift new file mode 100644 index 0000000000..3199f5b397 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/CoachSuggestions.swift @@ -0,0 +1,102 @@ +import Foundation +import WhoopStore + +// CoachSuggestions.swift — contextual coaching prompt chips derived from the user's own bands. +// +// Pure + deterministic so it is unit-testable without a strap, network, or app target, and so the +// output can be byte-identical to the Android twin `com.noop.analytics.CoachSuggestions` (the +// cross-platform parity contract: the same inputs produce the same chip strings on both platforms). +// Reads only DailyMetric fields that already live on-device; no new egress, no telemetry. + +/// Contextual suggestion chips for the Coach composer, derived from today's bands. +public enum CoachSuggestions { + + /// The stable generic set used when there is no usable data for today. Byte-identical to the + /// Android `FALLBACK` list. + public static let fallback: [String] = [ + "How's my recovery trending this week?", + "What should today's training look like?", + "Analyse my sleep", + "Why am I run down?", + ] + + /// A stable generic prompt always appended as the last chip so there is a consistent entry point + /// even when the contextual chips already cover the user's situation. + private static let stableGeneric = "Analyse my sleep" + + /// Charge band cutoffs (mirrors the autoregulation bands in `AICoachEngine.defaultSystemPrompt`). + private static let chargeRedCutoff: Double = 34 + private static let chargeGreenCutoff: Double = 67 + /// HRV "trending down" threshold: today's HRV below 85% of the trailing-30-day baseline (excluding + /// today) flags a downward nudge. Matches the Android twin. + private static let hrvDownRatio: Double = 0.85 + /// Sleep "poor night" cutoff: under 6h (360 min). + private static let poorSleepMin: Double = 360 + /// "Already loaded" strain cutoff: a day strain at/above 14 reads as a high-load day. + private static let highStrain: Double = 14 + /// Max chips surfaced. + private static let maxChips: Int = 4 + + /// Build 2–4 contextual prompt chips from today's bands, falling back to `fallback` when there is + /// no usable data. `recent` is oldest→newest and is used for the trailing-30-day HRV baseline. + /// + /// - Parameters: + /// - today: today's (or the newest scored) `DailyMetric`; nil when no data is available. + /// - recent: recent days oldest→newest, for the HRV baseline. May be empty or may include today. + /// - Returns: 2–4 chip strings (contextual) or the 4-string `fallback`. + public static func suggestions(for today: DailyMetric?, recent: [DailyMetric]) -> [String] { + guard let today else { return fallback } + let charge = today.recovery + let hrv = today.avgHrv + let sleep = today.totalSleepMin + let strain = today.strain + // No usable signal at all → generic fallback. + guard charge != nil || hrv != nil || sleep != nil || strain != nil else { return fallback } + + var chips: [String] = [] + + // 1. Charge band → one readiness prescription chip. + if let c = charge { + if c < chargeRedCutoff { + chips.append("Active recovery only today — what should I do?") + } else if c < chargeGreenCutoff { + chips.append("Quality over volume today — plan my session") + } else { + chips.append("Green light — how hard can I push today?") + } + } + + // 2. HRV trending down vs trailing-30-day baseline (excluding today). + if let h = hrv { + let baseline = hrvBaseline(recent, excluding: today.day) + if let avg = baseline, avg > 0, h < hrvDownRatio * avg { + chips.append("Why is my HRV trending down?") + } + } + + // 3. Poor sleep (< 6h). + if let s = sleep, s < poorSleepMin { + chips.append("I slept poorly — how do I recover today?") + } + + // 4. Already a high-strain day. + if let st = strain, st >= highStrain { + chips.append("Have I done enough today, or push more?") + } + + // Signals present but none of the band conditions fired → generic fallback (avoids a lone + // stable chip when there's nothing contextual to say). + guard !chips.isEmpty else { return fallback } + // Cap the CONTEXTUAL chips at maxChips−1 so the stable generic always survives as the last + // chip (a consistent entry point), then append it. Total never exceeds maxChips. + return Array(chips.prefix(maxChips - 1)) + [stableGeneric] + } + + /// Mean of `avgHrv` over the trailing 30 days of `recent`, excluding the day `excludingDay`. + /// Returns nil when no qualifying night has an HRV value. Pure; byte-twin of the Android helper. + private static func hrvBaseline(_ recent: [DailyMetric], excluding day: String) -> Double? { + let values = recent.suffix(30).filter { $0.day != day }.compactMap { $0.avgHrv } + guard !values.isEmpty else { return nil } + return values.reduce(0, +) / Double(values.count) + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/ConnectionReadout.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/ConnectionReadout.swift index 91d554dc5e..fdbc332703 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/ConnectionReadout.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/ConnectionReadout.swift @@ -227,23 +227,129 @@ public enum ConnectionReadout { if let d = deviceClockUnix { return d < ConnectionTrace.rtcEpochCeilingUnix ? "no (RTC reads 1970/71)" : "yes" } + // #1823: this branch has NOT read a clock. It is reached when no clock correlation exists - which + // is every 5/MG, whose GET_CLOCK reply rides the puffin notify chars and never touches the WHOOP4 + // correlation path - so the only evidence is how the strap DATED its banked records. Saying "RTC + // reads 1970/71" there claimed a reading we never took, in the one readout a reporter quotes when + // asking why nothing syncs. Report the evidence we actually have. if let n = strapNewestUnix { - return n < ConnectionTrace.rtcEpochCeilingUnix ? "no (RTC reads 1970/71)" : "yes" + return n < ConnectionTrace.rtcEpochCeilingUnix ? "no (records dated 1970/71)" : "yes" } return "no (waiting for the strap clock)" } + /// #1818: at or above this charge the "charge it" remedy is already satisfied, so repeating it is + /// noise. Twin of the Kotlin constant - the two must move together or the platforms give + /// different advice for the same strap. + public static let rtcAlreadyChargedPct: Double = 95 + /// #987: a plain-words warning when the strap RTC reads epoch-era (~1970/71), from EITHER signal we /// hold: the correlated device clock or the strap's newest banked-record timestamp. nil when both /// look sane (or neither was seen yet - we never fabricate a fault). One string, shown verbatim on /// the Devices / Test Centre connection readout, naming the consequence and the fix. - public static func rtcWarning(deviceClockUnix: Int?, strapNewestUnix: Int?) -> String? { + /// + /// #1818: the remedy is battery-dependent. A flat battery resets the RTC, so on a low strap + /// "charge it" is real advice. On an ALREADY-charged strap it is not, and the old copy sent users + /// at 100% round a loop they had already run. + /// + /// The charged branch deliberately states only what holds for EVERY strap - that charging again + /// will not change it - and asks for a log. It must NOT claim NOOP re-sends the clock on every + /// connect: that is true on WHOOP4 (`runConnectHandshake` calls SET_CLOCK unconditionally, both + /// payload forms, #120) but FALSE on a 5/MG, where the clock write is gated behind `didBond`, and + /// an unbondable 5/MG (#1635) is never clocked at all - precisely the strap most likely to be + /// showing this warning. Explaining the mechanism in the sentence is how the original bug happened. + /// `batteryPct` nil (not yet read) keeps the charge advice - we only withdraw it on evidence. + /// Callers MUST pass a reading from the CURRENT link (iOS: `batterySamples.last?.soc`, which is + /// cleared on disconnect) and not a last-known charge that outlives it - a stale 100% would + /// suppress the advice in the one case it is right, a strap that ran flat and reset its RTC. + public static func rtcWarning(deviceClockUnix: Int?, strapNewestUnix: Int?, + batteryPct: Double? = nil) -> String? { let ceiling = ConnectionTrace.rtcEpochCeilingUnix let clockBad = deviceClockUnix.map { $0 > 0 && $0 < ceiling } ?? false let newestBad = strapNewestUnix.map { $0 > 0 && $0 < ceiling } ?? false guard clockBad || newestBad else { return nil } - return "Strap clock reads 1970/71 (never set since its last reset), so it is not banking history. " - + "Charge the strap to 100% and reconnect so the clock latches." + let lead = "Strap clock reads 1970/71 (never set since its last reset), so it is not banking history. " + if let batteryPct, batteryPct >= rtcAlreadyChargedPct { + return lead + + "The strap is already charged, so charging again will not change this. Export a strap " + + "log from Test Centre so the clock exchange can be read." + } + return lead + "Charge the strap to 100% and reconnect so the clock latches." + } + + /// #1809: one-line account of a finished BLE link, logged on every disconnect. + /// + /// A strap log could not previously answer "did the strap send anything?". Inbound notifications only + /// stamped a liveness timestamp that was then discarded, and the disconnect error reached the log as + /// the OS `localizedDescription` while the `CBError` code the #617 branch computes was thrown away. A + /// reporter chasing a silent strap had to infer silence from the fact that every LOGGED line happened + /// to be outgoing - which measures NOOP's logging, not the strap. This measures the strap. + /// + /// `armed` matters because the #80 marginal-radio fallback only counts a drop when the R10/R11 burst + /// was actually armed; `armed=no` says up front that the detector cannot trip for this link, however + /// many times the loop repeats. + /// + /// Milliseconds are printed raw: no float formatting, so the two platforms cannot round apart. + public static func linkEpitaph(upMillis: Int, inboundFrames: Int, inboundBytes: Int, + cmdChannelFrames: Int, realtimeArmed: Bool, ended: String) -> String { + let armed = realtimeArmed ? "yes" : "no" + var line = "Link epitaph: up \(max(0, upMillis))ms, inbound \(max(0, inboundFrames)) frames / " + + "\(max(0, inboundBytes)) bytes (cmd-channel \(max(0, cmdChannelFrames))), " + + "realtime armed=\(armed), ended=\(ended)" + if inboundFrames <= 0 { + line += " - the strap sent NOTHING on this link" + } + return line + } + + /// #1635: what a finished link actually stored, split by PATH. + /// + /// The epitaph counts frames, which is right for a silent strap and wrong for one talking over only + /// part of its surface: an unbonded 5/MG streams heart rate and R-R all night while nothing + /// bond-gated lands, and the epitaph reports hundreds of healthy inbound frames. + /// + /// The split must be by PATH, not by stream. The realtime decoder produces only hr, rr, events and + /// battery; gravity, respiratory, skin temperature, SpO2 and steps arrive solely through the + /// historical decoder behind the offload. A live-only tally therefore printed "nothing banked live + /// for: gravity, resp, …" on EVERY link, bonded or not — a constant dressed as a finding. + /// + /// The offload is where the bond shows: an unbonded strap defers backfill entirely, so `offload none` + /// is the real signal and a healthy sync fills it. + /// + /// Battery is absent on purpose — it rides the standard 0x2A19 profile and banks with or without the + /// bond. `offloadSteps` is optional: a platform that cannot measure a stream omits it rather than + /// reporting a zero that reads as a fault. Counts are rows ACCEPTED. Pure, total and clamped. + /// Twin of the Kotlin formatter. + public static func linkBankedSummary(liveHr: Int, liveRr: Int, offloadChunks: Int, + offloadHr: Int, offloadRr: Int, offloadGravity: Int, + offloadResp: Int, offloadSkinTemp: Int, offloadSpo2: Int, + offloadSteps: Int?) -> String { + let live = "live hr=\(max(0, liveHr)) rr=\(max(0, liveRr))" + var raw: [(String, Int)] = [ + ("hr", offloadHr), ("rr", offloadRr), ("gravity", offloadGravity), ("resp", offloadResp), + ("skinTemp", offloadSkinTemp), ("spo2", offloadSpo2), + ] + if let offloadSteps { raw.append(("steps", offloadSteps)) } + let offload = raw.map { ($0.0, max(0, $0.1)) } + let total = offload.reduce(0) { $0 + $1.1 } + // Three states, reported as FACTS rather than verdicts. "No chunks" is not evidence of a fault on + // its own: a short or command-only link never reaches backfill, and the reason is already logged + // beside it. The epitaph supplies the uptime a reader needs to weigh it. + // + // "Never ran" and "ran with nothing new" are still DIFFERENT. Rows are counted as ACCEPTED, so + // a reconnect re-offloading already-stored records banks zero while the strap plainly handed its + // history over. Only the first case speaks to the bond. + if (max(0, offloadChunks)) == 0 { + return "banked this link: \(live) | offload none" + } + if total == 0 { + return "banked this link: \(live) | offload ran \(max(0, offloadChunks)) chunk(s), no new rows" + } + let body = offload.map { "\($0.0)=\($0.1)" }.joined(separator: " ") + let empty = offload.filter { $0.1 == 0 }.map { $0.0 } + if empty.isEmpty { return "banked this link: \(live) | offload \(body)" } + return "banked this link: \(live) | offload \(body)" + + " - nothing banked from the offload for: \(empty.joined(separator: ", "))" } /// #987: freshness label for the "last frame" readout row: how long ago the most recent strap frame diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/DayCycle.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/DayCycle.swift new file mode 100644 index 0000000000..a2218230f0 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/DayCycle.swift @@ -0,0 +1,70 @@ +import Foundation + +public enum DayCycleMode: String, CaseIterable, Sendable { + case sleepOnset = "sleep_onset" + case midnight = "midnight" + + public static let storageKey = "noop.dayCycleMode" + /// Kotlin twin: `DayCycleMode.fromPersisted`. + public static func persisted(_ value: String?) -> DayCycleMode { + value.flatMap(Self.init(rawValue:)) ?? .sleepOnset + } +} + +public struct DayCycleWindow: Equatable, Sendable { + public enum Source: Equatable, Sendable { case detectedSleep, editedSleep, syntheticMidnight, calendar } + public let id: String + public let startInclusive: Int + public let endExclusive: Int + public let displayDay: String + public let source: Source + + public init(id: String, startInclusive: Int, endExclusive: Int, displayDay: String, source: Source) { + self.id = id; self.startInclusive = startInclusive; self.endExclusive = endExclusive + self.displayDay = displayDay; self.source = source + } +} + +public enum DayCycleResolver { + public static let minSyntheticMidnightAgeSeconds = 18 * 3_600 + public static let absoluteMaxOpenSeconds = 40 * 3_600 + + /// Kotlin twin: `DayCycleResolver.calendarWindow`. + public static func calendarWindow(now: Int, offsetSec: Int) -> DayCycleWindow { + let local = now + offsetSec + let dayNumber = Int(floor(Double(local) / Double(SleepStageTotals.secondsPerDay))) + let start = dayNumber * SleepStageTotals.secondsPerDay - offsetSec + let day = AnalyticsEngine.dayString(start, offsetSec: offsetSec) + return DayCycleWindow(id: "calendar:\(day)", startInclusive: start, endExclusive: now, + displayDay: day, source: .calendar) + } + + /// Kotlin twin: `DayCycleResolver.fallbackMidnightAfter`. + public static func fallbackMidnight(after start: Int, offsetSec: Int) -> Int { + let minimum = start + minSyntheticMidnightAgeSeconds + let local = minimum + offsetSec + let dayNumber = Int(floor(Double(local) / Double(SleepStageTotals.secondsPerDay))) + let midnight = dayNumber * SleepStageTotals.secondsPerDay - offsetSec + return midnight >= minimum ? midnight : (dayNumber + 1) * SleepStageTotals.secondsPerDay - offsetSec + } + + /// Kotlin twin: `DayCycleResolver.activeWindow`. + public static func activeWindow(mode: DayCycleMode, latestSleep: DayCycleWindow?, now: Int, + offsetSec: Int) -> DayCycleWindow { + guard mode == .sleepOnset, let latestSleep else { return calendarWindow(now: now, offsetSec: offsetSec) } + let age = now - latestSleep.startInclusive + let fallback = fallbackMidnight(after: latestSleep.startInclusive, offsetSec: offsetSec) + // Sleep-onset mode stays anchored across midnight unconditionally: only the absolute safety cap + // may synthesize a fallback boundary. An earlier design gated this on whether awake coverage was + // reliable and carried a `reliableAwakeCoverage` parameter for it; the gate was dropped but the + // parameter survived, unread on both platforms and passed `false` by every one of its five call + // sites. Removed rather than left looking like a switch someone could flip. + guard age < absoluteMaxOpenSeconds else { + let day = AnalyticsEngine.dayString(fallback, offsetSec: offsetSec) + return DayCycleWindow(id: "synthetic:\(day)", startInclusive: fallback, endExclusive: now, + displayDay: day, source: .syntheticMidnight) + } + return DayCycleWindow(id: latestSleep.id, startInclusive: latestSleep.startInclusive, + endExclusive: now, displayDay: latestSleep.displayDay, source: latestSleep.source) + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/EffectRanker.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/EffectRanker.swift index 0ff4645c04..aad7f5068b 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/EffectRanker.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/EffectRanker.swift @@ -104,13 +104,18 @@ public enum EffectRanker { /// significant-first, |cohensD| desc, then behaviour name asc. Behaviours with no /// computable lag are dropped. public static func rank(behaviors: [String: Set], + controls: [String: Set], outcomeByDay: [String: Double], outcome: String) -> [RankedEffect] { var rows: [RankedEffect] = [] // Sort behaviour names so the build order is deterministic regardless of dict order. for name in behaviors.keys.sorted() { let days = behaviors[name]! - if let row = bestLag(behaviorDays: days, outcomeByDay: outcomeByDay, + // Absent from `controls` means no control group, which yields nothing — see + // BehaviorInsights.effect. Failing closed is deliberate: a caller that forgets loses the + // insight rather than getting one measured against every unlogged day. + if let row = bestLag(behaviorDays: days, controlDays: controls[name] ?? [], + outcomeByDay: outcomeByDay, behavior: name, outcome: outcome) { rows.append(row) } @@ -121,6 +126,7 @@ public enum EffectRanker { /// Find the best-lag RankedEffect for ONE behaviour against ONE outcome, or nil when no /// lag in `lagSet` yields a computable effect that clears the group gate. public static func bestLag(behaviorDays: Set, + controlDays: Set, outcomeByDay: [String: Double], behavior: String, outcome: String) -> RankedEffect? { @@ -128,6 +134,7 @@ public enum EffectRanker { for lag in lagSet { let shifted = shiftedOutcome(outcomeByDay, byLag: lag) guard let e = BehaviorInsights.effect(behaviorDays: behaviorDays, + controlDays: controlDays, outcomeByDay: shifted, behavior: behavior, outcome: outcome) else { continue } diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/HRVAnalyzer.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/HRVAnalyzer.swift index 99eb32fbc3..fc1c66671c 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/HRVAnalyzer.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/HRVAnalyzer.swift @@ -424,10 +424,11 @@ public enum HRVAnalyzer { /// arriving in real time, carrying no timestamps to measure coverage with — and suppressing those /// would refuse honest readings, the opposite of the point. /// - /// Successive-difference statistics (RMSSD, pNN50) are deliberately NOT gated here. Their dominant - /// error on a banked stream was the lost within-second emission order (#823, root-caused in #1072), - /// which is fixed at the write path; whether they need a gate of their own is a question for a - /// post-fix capture to answer, not an assumption to bake in now. + /// Successive-difference statistics (RMSSD, pNN50) are not gated HERE, but they are no longer ungated + /// by COVERAGE: see `successiveDiffIsTrustworthy` below. The question this comment left open was one for + /// a post-fix capture to answer; that capture arrived and answered it yes. Note it answered THIS + /// question only. The sibling deferral on `beatValuesAreTrustworthy`, about beat-to-beat accuracy, is + /// still open and still waiting on a capture of its own. public static func beatSpreadIsTrustworthy(_ verdict: RrCoverageVerdict) -> Bool { switch verdict { case .sameSecondOverCount, .crossSecondOverCount: @@ -437,6 +438,51 @@ public enum HRVAnalyzer { } } + /// Whether RMSSD / pNN50 can be trusted from a window with this coverage verdict (#1118). + /// + /// The sibling of `beatSpreadIsTrustworthy`, and the same two verdicts refuse, but for its own reason + /// rather than by borrowing that one's. SDNN is a spread over every interval, so a duplicated beat + /// widens it. Successive-difference statistics fail the other way: a beat the heart never produced + /// sits next to its neighbour with a near-zero difference, and RMSSD is the root mean square of + /// exactly those differences, so an over-counted night reads LOW. Measured on a WHOOP 4.0 night whose + /// banked R-R covers 2.13× the wall clock it spans, RMSSD reads 36 ms where the same night's beats + /// collapsed read 41 ms; the direction of the error is not obvious from the number, which is precisely + /// why it cannot be shown. + /// + /// The comment on `beatSpreadIsTrustworthy` used to say these were deliberately ungated because their + /// dominant error was the lost within-second emission order fixed at the write path (#823 / #1072), + /// and that whether they needed a gate of their own was for a post-fix capture to answer. The capture + /// arrived: a build carrying that fix, on a WHOOP 4.0, still reports `crossSecondOverCount` on every + /// measured night, with 91% of the inflating beat-time on seconds written by more than one delivery. + /// The write-path fix did not make the stream clean, so the assumption it licensed no longer holds. + /// + /// WITHHOLD, DO NOT FABRICATE. This trades a wrong number for a blank on every affected night, which + /// is a real cost: HRV is the dominant Charge driver, so a gated night drops that term and renormalises + /// the rest. That cost is accepted deliberately. A contaminated RMSSD does not merely display wrong, it + /// enters the personal baseline the following nights are scored against, so showing it spreads the + /// error into days whose own capture was clean. + /// + /// THE COST REACHES FURTHER THAN THE CARD, and a reader should know it before widening this rule. + /// `Baselines.update` holds on a nil night without advancing `nValid`, so on an install whose EVERY + /// night gates, the HRV baseline never reaches `Baselines.minNightsSeed` and stays calibrating + /// indefinitely. That is the WHOOP 4.0-only case exactly. It is still the right answer, because the + /// alternative is seeding a personal baseline from beats the heart did not produce and scoring every + /// later night against it, but it means this gate can leave a strap with no HRV baseline at all rather + /// than merely a gap in one. A mixed install is unaffected: its clean nights seed the baseline and the + /// gated ones skip-and-hold. + /// + /// `underCovered` and `unmeasurable` stay trusted for the same reason they do above: neither duplicates + /// a beat, and `unmeasurable` is what an honest live spot reading looks like. Pure. Byte-parity twin of + /// Kotlin `successiveDiffIsTrustworthy`. + public static func successiveDiffIsTrustworthy(_ verdict: RrCoverageVerdict) -> Bool { + switch verdict { + case .sameSecondOverCount, .crossSecondOverCount: + return false + case .plausible, .underCovered, .unmeasurable: + return true + } + } + /// Whether a live spot capture collected more beat time than the wall clock allows (no per-beat /// timestamps for `rrCoverage`, but the capture knows how long it ran). Only over-count rejects; /// sparse windows stay with the `minBeats` gate. Pure. Byte-parity twin of Kotlin @@ -499,8 +545,12 @@ public enum HRVAnalyzer { /// median, so no per-beat artifact rule can see the fault — it is in the decomposition, not in /// outliers. /// - /// Successive-difference statistics (RMSSD, pNN50) are deliberately NOT gated here, for the same - /// reason they are not gated by the coverage verdict: that is a question for a capture to answer. + /// Successive-difference statistics (RMSSD, pNN50) are deliberately NOT gated here. They ARE now gated + /// on the coverage verdict (`successiveDiffIsTrustworthy`), but that is a different fault and a + /// different population, and the capture that justified it says nothing about this one. Gating here + /// would sweep in the very night described above: coverage 1.03, verdict plausible, and untrustworthy + /// only in its decomposition. Refusing those needs its own capture, and inventing one from a WHOOP 4.0 + /// over-count would be exactly the assumption the paragraph above refused to bake in. public static func beatValuesAreTrustworthy(beatAccurateFraction: Double) -> Bool { // Negated `<` so a NaN input lands on `true` — NaN means "not measured", and an unmeasured // window must not be silently refused. Matches the NaN convention in `classifyCoverage`. @@ -808,8 +858,21 @@ public enum HRVAnalyzer { static func msToInt(_ ms: Double) -> Int { ms > 0 ? Int(ms + 0.5) : 0 } /// Whole-percent, integer half-up, so both platforms round a tie the same way. 0 when `total` is 0. + /// + /// The arithmetic is widened to `Int64` because `deliveryHistogram` passes BEAT-TIME IN + /// MILLISECONDS summed over a whole night, not a row count. The numerator is `part * 200 + total`, + /// so with `part <= total` it needs more than 32 bits from `total >= 10,683,999` ms (2.97 h of + /// beat-time on multi-delivery seconds) and a real over-count night carries 3-4x that. The multiply + /// ALONE would survive to `part >= 10,737,419`; the `+ total` term is what brings the bound down, so + /// quoting the multiply's limit understates the exposure. `Int` is 64-bit on iOS/macOS so this + /// platform read correctly while the Kotlin twin wrapped; it is 32-bit on `arm64_32`, and + /// StrandAnalytics already builds for watchOS, so the width is stated rather than inherited. + /// + /// The wrap did not always land negative, which is the dangerous half: a 100% multi-share 8 h night + /// (`pct(28_800_000, 28_800_000)`) returned 25 in 32 bits - positive, plausible, and wrong by 75 + /// points. A negative percentage was a symptom of this, never the test for it. Twin of Kotlin `pct`. static func pct(_ part: Int, _ total: Int) -> Int { - total > 0 ? (part * 200 + total) / (total * 2) : 0 + total > 0 ? Int((Int64(part) * 200 + Int64(total)) / (Int64(total) * 2)) : 0 } /// #1008: a compact, deterministic RAW-ROW sample of the beats around the DENSEST second, for the diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/HeartRateRecovery.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/HeartRateRecovery.swift index 43fe3a2f3e..6c05ee5e37 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/HeartRateRecovery.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/HeartRateRecovery.swift @@ -82,13 +82,23 @@ public enum HeartRateRecovery { private static func sustainedSeconds(atOrAbove threshold: Double, in samples: [HRSample]) -> Int { guard samples.count >= 2 else { return 0 } - var seconds = 0 + var currentSeconds = 0 + var longestSeconds = 0 for i in 0..<(samples.count - 1) { let gap = samples[i + 1].ts - samples[i].ts - guard gap > 0, gap <= maximumContinuousGapSeconds else { continue } - if Double(samples[i].bpm) >= threshold { seconds += gap } + guard gap > 0 else { continue } + guard gap <= maximumContinuousGapSeconds else { + currentSeconds = 0 + continue + } + guard Double(samples[i].bpm) >= threshold else { + currentSeconds = 0 + continue + } + currentSeconds += gap + longestSeconds = max(longestSeconds, currentSeconds) } - return seconds + return longestSeconds } private static func median(_ values: [Int]) -> Int? { diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/LivePersistTrace.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/LivePersistTrace.swift new file mode 100644 index 0000000000..d4e9cc9b38 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/LivePersistTrace.swift @@ -0,0 +1,137 @@ +import Foundation + +/// Maps the app's persistence-relevant lifecycle edges onto the reason carried through Collector logs. +/// The app and tests call this same seam, so lifecycle routing is exercised without UIKit on Linux. +public enum StandardHRLifecycleFlush { + public enum Event { + case background + case termination + } + + public static func run( + event: Event, + flush: (LivePersistTrace.StandardHRFlushReason) async -> Void + ) async { + switch event { + case .background: + await flush(.background) + case .termination: + await flush(.termination) + } + } +} + +/// A live HR/R-R batch failed to persist and was re-buffered. +/// +/// `Collector` re-queues the frames and swallows the error, so a store rejecting every insert produces a +/// log full of `rr emit … offered=N` and no indication that none of it landed. That is the worst shape a +/// diagnostic gap can take: the instrumentation that exists reads like success. +/// +/// Twin of the Kotlin `liveInsertFailedLine` in `com.noop.ble.StalledLinkDiagnostics`, so an Android and +/// an Apple log of the same failure compare directly. Rendered strings match for ASCII (store errors). +/// The 200-character bound is not a Unicode-identical truncation: Kotlin `take(200)` counts UTF-16 code +/// units and Swift `prefix(200)` counts grapheme clusters. Store error descriptions are ASCII, which is +/// the load-bearing case. The Kotlin side lives beside its caller in the BLE package; this one lives here +/// because `Collector` is app-target Swift with no default CI, and the package is where the other +/// emitted-line builders (`Spo2ReTrace`, `SleepStager+Trace`, `ConnectionReadout`) already sit and get tested. +public enum LivePersistTrace { + + public enum StandardHRFlushReason: String { + case cadence + case disconnect + case background + case termination + case explicit + } + + /// Bounded standard-HR transport-state diagnostics. These lines describe host observation and + /// buffer/persistence state only; they carry no physiological measurements and make no claim + /// about sensor-origin time or unobserved loss. + public static func standardHRHostReceivedLine( + hostUnixSeconds: Int, + acceptedHRRows: Int, acceptedRRRows: Int, + rejectedHRRows: Int, rejectedRRRows: Int, + pendingHRRows: Int, pendingRRRows: Int + ) -> String { + "standard-hr transport host-received hostUnixSec=\(hostUnixSeconds)" + + " acceptedHRRows=\(acceptedHRRows) acceptedRRRows=\(acceptedRRRows)" + + " rejectedHRRows=\(rejectedHRRows) rejectedRRRows=\(rejectedRRRows)" + + " pendingHRRows=\(pendingHRRows) pendingRRRows=\(pendingRRRows)" + } + + public static func standardHRFlushAttemptLine( + reason: StandardHRFlushReason, offeredHRRows: Int, offeredRRRows: Int + ) -> String { + "standard-hr transport flush-attempt reason=\(reason.rawValue)" + + " offeredHRRows=\(offeredHRRows) offeredRRRows=\(offeredRRRows)" + } + + public static func standardHRFlushSucceededLine( + reason: StandardHRFlushReason, offeredHRRows: Int, offeredRRRows: Int, + insertedHRRows: Int, insertedRRRows: Int + ) -> String { + "standard-hr transport flush-succeeded reason=\(reason.rawValue)" + + " offeredHRRows=\(offeredHRRows) offeredRRRows=\(offeredRRRows)" + + " insertedHRRows=\(insertedHRRows) insertedRRRows=\(insertedRRRows)" + } + + public static func standardHRRebufferedForRetryLine( + reason: StandardHRFlushReason, attemptedHRRows: Int, attemptedRRRows: Int, + pendingHRRows: Int, pendingRRRows: Int, consecutiveFailures: Int + ) -> String { + "standard-hr transport rebuffered-for-retry reason=\(reason.rawValue)" + + " attemptedHRRows=\(attemptedHRRows) attemptedRRRows=\(attemptedRRRows)" + + " pendingHRRows=\(pendingHRRows) pendingRRRows=\(pendingRRRows)" + + " consecutiveFailures=\(consecutiveFailures)" + } + + /// - Parameters: + /// - transport: which live path failed. There are TWO — the standard 0x2A37 reading and the puffin + /// REALTIME_DATA batch (#1118) — and they fail independently. A line that did not say which would + /// leave a reader unable to tell one dead transport from a dead store, the first fork in the + /// diagnosis. + /// - errorName: the error's type name. Spelled `throwableName` in the Kotlin twin; the rendered + /// line is identical. + /// - consecutiveFailures: matters more than any single error. One failure is the transient the + /// re-buffer exists to absorb; a climbing count is a store that will never accept these rows, and + /// only the count separates them. + public static func liveInsertFailedLine( + transport: String, + errorName: String, + message: String?, + hrFrames: Int, + rrFrames: Int, + consecutiveFailures: Int + ) -> String { + // Bounded like the Kotlin `take(200)`: a full error description can be enormous, and the useful + // cases (a full disk, a corrupted database, a schema mismatch) are distinguished well inside it. + // ASCII-only twin: `take` is UTF-16 code units, `prefix` is grapheme clusters. + let detail = message.flatMap { m -> String? in + let trimmed = m.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : ": " + String(m.prefix(200)) + } ?? "" + let run = consecutiveFailures >= 2 + ? " \(consecutiveFailures) consecutive failures — these rows are not landing and the re-buffer is" + + " not recovering them." + : " Re-buffered for the next cadence." + return "Live persist FAILED on \(transport) — \(errorName)\(detail) (hr=\(hrFrames) rr=\(rrFrames))." + + run + } + + /// Rate limit for ``liveInsertFailedLine(transport:errorName:message:hrFrames:rrFrames:consecutiveFailures:)``. + /// + /// The live cadence is seconds, so an unconditional log would bury the rest of the capture under a + /// failure it has already reported. The gap is deliberately long: this line establishes THAT inserts + /// are failing and roughly for how long, which one line a minute answers as well as sixty. + /// + /// A zero `lastEmitMs` must emit — the first failure is the one most worth having. A BACKWARDS clock + /// emits too: wall time can step back, and comparing only forwards would strand `lastEmitMs` in the + /// future and silence the line until real time caught up. Byte-identical rule to the Kotlin twin. + public static func shouldEmitLiveInsertFailure( + lastEmitMs: Int64, + nowMs: Int64, + minGapMs: Int64 = 60_000 + ) -> Bool { + lastEmitMs <= 0 || nowMs < lastEmitMs || nowMs - lastEmitMs >= minGapMs + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/LocalDayWindows.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/LocalDayWindows.swift new file mode 100644 index 0000000000..fe4e1e1013 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/LocalDayWindows.swift @@ -0,0 +1,364 @@ +import Foundation + +/// A local calendar date as a year/month/day triple, mirroring the components of the Kotlin twin's +/// `java.time.LocalDate`. +/// +/// Swift has no calendar-date type that is free of a `Calendar`, and a `Calendar` is exactly what this +/// helper must not lean on: the day-window rules in this file are stated explicitly so that a future +/// change of library behaviour cannot move a day boundary silently. So the date is carried as its own +/// value type and converted to and from a day number with proleptic-Gregorian arithmetic. +public struct LocalCalendarDate: Hashable, Comparable, Sendable { + + /// The proleptic Gregorian year, negative before year 1. + public let year: Int + + /// The month, 1...12. + public let month: Int + + /// The day of the month, 1...31. No validation happens here: an out-of-range day is normalised by + /// the day-number conversion, the same way `java.time` would reject it and callers here never + /// construct one. + public let day: Int + + /// Build a date from its components. + public init(year: Int, month: Int, day: Int) { + self.year = year + self.month = month + self.day = day + } + + /// Build the date that lies `daysSinceEpoch` days after 1970-01-01. + public init(daysSinceEpoch: Int) { + // Howard Hinnant's civil_from_days, which is exact over the whole Int range and needs no + // calendar object. Divisions truncate toward zero in Swift as they do in C, which is what the + // era shifts below are written for. + var z = daysSinceEpoch + 719_468 + let era = (z >= 0 ? z : z - 146_096) / 146_097 + z -= era * 146_097 + let dayOfEra = z + let yearOfEra = (dayOfEra - dayOfEra / 1460 + dayOfEra / 36_524 - dayOfEra / 146_096) / 365 + let dayOfYear = dayOfEra - (365 * yearOfEra + yearOfEra / 4 - yearOfEra / 100) + let monthPrime = (5 * dayOfYear + 2) / 153 + let d = dayOfYear - (153 * monthPrime + 2) / 5 + 1 + let m = monthPrime + (monthPrime < 10 ? 3 : -9) + let y = yearOfEra + era * 400 + (m <= 2 ? 1 : 0) + self.init(year: y, month: m, day: d) + } + + /// The number of days from 1970-01-01 to this date, negative before it. + public var daysSinceEpoch: Int { + // Howard Hinnant's days_from_civil, the exact inverse of the initialiser above. + let y = year - (month <= 2 ? 1 : 0) + let era = (y >= 0 ? y : y - 399) / 400 + let yearOfEra = y - era * 400 + let dayOfYear = (153 * (month + (month > 2 ? -3 : 9)) + 2) / 5 + day - 1 + let dayOfEra = yearOfEra * 365 + yearOfEra / 4 - yearOfEra / 100 + dayOfYear + return era * 146_097 + dayOfEra - 719_468 + } + + /// The date rendered as `YYYY-MM-DD`, which is also the day key this helper hands out. + public var key: String { + let y = year < 0 ? "-" + Self.pad(-year, 4) : Self.pad(year, 4) + return "\(y)-\(Self.pad(month, 2))-\(Self.pad(day, 2))" + } + + /// The date `count` days after this one, negative counts moving backward. + public func adding(days count: Int) -> LocalCalendarDate { + LocalCalendarDate(daysSinceEpoch: daysSinceEpoch + count) + } + + /// Order dates the way the calendar does. + public static func < (lhs: LocalCalendarDate, rhs: LocalCalendarDate) -> Bool { + if lhs.year != rhs.year { return lhs.year < rhs.year } + if lhs.month != rhs.month { return lhs.month < rhs.month } + return lhs.day < rhs.day + } + + /// Left-pad a non-negative number with zeroes, without a formatter — the twin does the same, and a + /// locale-aware formatter is one more thing that could differ between the platforms. + private static func pad(_ value: Int, _ width: Int) -> String { + var s = String(value) + while s.count < width { s = "0" + s } + return s + } +} + +/// One local day as a half-open window, with the UTC offset that was in effect when it began. +public struct LocalDayWindow: Hashable, Sendable { + + /// The date this window belongs to. + public let date: LocalCalendarDate + + /// The first instant of the date. + public let start: Date + + /// The start of the next date that exists in the zone, which is where this window ends. It is not + /// always `start` plus 24 hours, and where a calendar date is skipped entirely it is more than a + /// day away. + public let nextStart: Date + + /// The UTC offset in effect at `start`, in seconds east of UTC. + /// + /// It describes the window's start only. A time of day anywhere else inside the window is derived + /// from the zone, never by adding this number, because a transition inside the window would make + /// that wrong by an hour. + public let utcOffsetSeconds: Int + + /// Build a window from its parts. + public init(date: LocalCalendarDate, start: Date, nextStart: Date, utcOffsetSeconds: Int) { + self.date = date + self.start = start + self.nextStart = nextStart + self.utcOffsetSeconds = utcOffsetSeconds + } + + /// How long the window lasts — 23, 24 or 25 hours in the fixture zones, and longer where the zone + /// skips a calendar date. + public var duration: TimeInterval { + nextStart.timeIntervalSince(start) + } +} + +/// One entry of a backward run: a date that exists in the zone, with the start resolved for it. +public struct LocalDayStart: Hashable, Sendable { + + /// The date. + public let date: LocalCalendarDate + + /// The first instant of that date in the zone. + public let start: Date + + /// Build an entry from its parts. + public init(date: LocalCalendarDate, start: Date) { + self.date = date + self.start = start + } +} + +/// When a local calendar date begins and ends in a time zone, taken from the zone's own rules. +/// +/// The analysis core answers this question by adding fixed 86,400-second blocks to a midnight derived +/// from a single UTC offset. That is wrong by an hour on every day boundary beyond a transition, +/// because a day on which the clocks move is 23 or 25 hours long. This type is the shared primitive +/// that answers it correctly; it changes no caller on its own. +/// +/// SO TWO ANSWERS COEXIST, and this one is not yet the shipped one. `AnalyticsEngine.dayStartUtcSeconds` +/// with `AnalyticsEngine.dayString` remains what every scored day actually uses; this file is called by +/// nothing but its own tests until a switch-over lands. They disagree by an hour on the far side of a +/// transition, which `LocalDayWindowsTests` pins deliberately by asserting BOTH answers for +/// America/New_York on 2025-10-19. Reach for the core unless you are writing that switch-over. +/// +/// Kotlin twin: `LocalDayWindows`. +/// +/// Nothing here is inherited from a calendar: the ambiguous, skipped and non-existent cases are +/// resolved by the rules stated on each operation, so that a future change in library behaviour is a +/// change to this file rather than a silent change of a day boundary. The only thing asked of the +/// platform is `TimeZone.secondsFromGMT(for:)`, the zone's raw rule lookup. +/// +/// Both the zone and the reference instant are injectable and default to the named accessors +/// `defaultTimeZone` and `defaultReferenceInstant`, so every rule below is provable without a device. +public struct LocalDayWindows: Sendable { + + /// The zone used when none is given: the auto-updating current zone, which follows a change of + /// device zone during the process's life. The Kotlin twin's default is the system default zone id, + /// which means the same thing. + /// + /// The zone is bound when the helper is constructed; a caller that must follow a device-zone change + /// during the process's life constructs a new helper. + public static var defaultTimeZone: TimeZone { TimeZone.autoupdatingCurrent } + + /// The reference instant used when none is given: the current `Date`. The Kotlin twin's default is + /// the current `Instant`. + public static var defaultReferenceInstant: Date { Date() } + + /// The zone whose rules every operation reads. + public let timeZone: TimeZone + + /// The instant that stands for "now" — it clamps the sleep read window and nothing else. No start, + /// window, key or run depends on it. + public let referenceInstant: Date + + /// Build a helper for a zone and a reference instant, both defaulting to the named accessors. + public init(timeZone: TimeZone = LocalDayWindows.defaultTimeZone, + referenceInstant: Date = LocalDayWindows.defaultReferenceInstant) { + self.timeZone = timeZone + self.referenceInstant = referenceInstant + } + + /// The number of seconds either side of a local midnight that is probed for the offsets in effect + /// around it. Two days is far more than any transition needs and still far less than the gap + /// between two transitions anywhere in the database. + private static let probeWindow: Int64 = 2 * 86_400 + + /// The first instant that belongs to `date` in this zone, or `nil` when the zone has no instant on + /// that date at all. + /// + /// The rule, stated rather than inherited: where local midnight exists once, that is the start; + /// where it exists twice, the earlier of the two; where it does not exist, the first instant that + /// does exist on the date, which is the instant the clocks jumped to. Where that jump lands on a + /// later date the date itself never happened, and there is no start. + /// + /// The rule assumes at most one offset change within two days either side of the local midnight; no + /// zone in the database violates that today. + /// + /// The result does not depend on `referenceInstant`. + public func start(of date: LocalCalendarDate) -> Date? { + let localMidnight = Int64(date.daysSinceEpoch) * 86_400 + let offsetBefore = offsetSeconds(atEpochSeconds: localMidnight - Self.probeWindow) + let offsetAfter = offsetSeconds(atEpochSeconds: localMidnight + Self.probeWindow) + + var candidates = [localMidnight - Int64(offsetBefore), localMidnight - Int64(offsetAfter)].sorted() + if candidates.count == 2 && candidates[0] == candidates[1] { candidates.removeLast() } + // An instant maps to this local midnight exactly when reading it in the zone gives that wall + // time back. Ascending order means an ambiguous midnight yields the earlier instant first. + for candidate in candidates + where candidate + Int64(offsetSeconds(atEpochSeconds: candidate)) == localMidnight { + return Date(timeIntervalSince1970: TimeInterval(candidate)) + } + + // Local midnight fell in a gap. The first instant that exists on or after it is the instant the + // clocks jumped at; it belongs to this date only if it still reads as this date. + guard offsetBefore != offsetAfter else { return nil } + let jump = transitionEpochSeconds(around: localMidnight, offsetBefore: offsetBefore) + guard localDate(ofEpochSeconds: jump) == date else { return nil } + return Date(timeIntervalSince1970: TimeInterval(jump)) + } + + /// The half-open window `[start, nextStart)` of `date`, or `nil` when the date does not exist. + /// + /// `nextStart` is the start of the next date that *exists*, so a window that precedes a skipped + /// calendar date spans more than a day rather than ending at an instant that never happened. + public func window(of date: LocalCalendarDate) -> LocalDayWindow? { + guard let dayStart = start(of: date) else { return nil } + var next = date.adding(days: 1) + var nextStart: Date? + // A zone skipping more than a couple of consecutive dates has never happened; the bound keeps a + // corrupt zone from turning this into an unbounded walk. + for _ in 0..<8 { + if let candidate = start(of: next) { + nextStart = candidate + break + } + next = next.adding(days: 1) + } + guard let end = nextStart else { return nil } + return LocalDayWindow(date: date, + start: dayStart, + nextStart: end, + utcOffsetSeconds: offsetSeconds(at: dayStart)) + } + + /// The start of the local day that contains `instant`, as an instant and without formatting a date + /// on the way. + public func startOfDay(containing instant: Date) -> Date { + let date = localDate(of: instant) + // `instant` lies on `date` by construction, so the date exists and has a start. The fallback + // is unreachable and exists only to keep the operation total. + guard let start = start(of: date) else { return instant } + return start + } + + /// The `YYYY-MM-DD` key of the local day that contains `instant`. + /// + /// The key and the window agree by construction: the key names the date whose window contains the + /// instant, because both are derived from the same zone lookup. + public func dayKey(for instant: Date) -> String { + localDate(of: instant).key + } + + /// The local calendar date that contains `instant` in this zone. + public func localDate(of instant: Date) -> LocalCalendarDate { + localDate(ofEpochSeconds: Int64(instant.timeIntervalSince1970.rounded(.down))) + } + + /// Up to `count` consecutive local calendar dates ending at `date`, oldest first, each with its own + /// resolved start. + /// + /// A date the zone skipped is omitted rather than replaced, so the run can be shorter than + /// requested; if `date` itself does not exist, the newest entry is the latest existing date before + /// it. + public func backwardRun(endingAt date: LocalCalendarDate, count: Int) -> [LocalDayStart] { + guard count > 0 else { return [] } + let endDay = date.daysSinceEpoch + var run: [LocalDayStart] = [] + run.reserveCapacity(count) + for back in stride(from: count - 1, through: 0, by: -1) { + let day = LocalCalendarDate(daysSinceEpoch: endDay - back) + if let start = start(of: day) { + run.append(LocalDayStart(date: day, start: start)) + } + } + return run + } + + /// The end of the sleep read window of `date`: the earlier of that date's next start and the + /// reference instant, so the current day's window never reaches into the future. `nil` when the + /// date does not exist. + /// + /// For a date that begins after the reference instant the end precedes the start — an empty, + /// inverted window — by the D7 contract; a caller wanting a non-empty window must not ask for a + /// future date. + public func sleepReadWindowEnd(of date: LocalCalendarDate) -> Date? { + guard let window = window(of: date) else { return nil } + return min(window.nextStart, referenceInstant) + } + + /// The UTC offset in effect at `instant`, in seconds east of UTC. + public func offsetSeconds(at instant: Date) -> Int { + timeZone.secondsFromGMT(for: instant) + } + + /// The time-zone database version this platform can observe about itself, or `nil` where it cannot. + /// + /// Only Darwin exposes one; on Linux the Foundation port has no equivalent, and saying so is the + /// point — a faked value would claim a fact nobody checked. + public static var observedTimeZoneDataVersion: String? { + #if canImport(Darwin) + return NSTimeZone.timeZoneDataVersion + #else + return nil + #endif + } + + /// The note a platform attaches when a fixture value moves: it names the version recorded in the + /// oracle and the version this platform observes at run time. + /// + /// A version difference is not itself a failure — the fixtures are past and stable, so a routine + /// toolchain bump must not redden unrelated work. The note exists to make a *value* difference + /// explicable. It is a pure function so that its content can be asserted; where a platform cannot + /// observe its own version, the note says so in place of a version rather than omitting it. + public static func timeZoneDataVersionNote(recorded: String, observed: String?) -> String { + let observedText = observed.map { "observes \($0)" } + ?? "cannot observe its own time-zone database version" + return "time-zone database version: the oracle records \(recorded); this platform \(observedText)." + } + + /// The UTC offset in effect at a whole-second instant. + private func offsetSeconds(atEpochSeconds seconds: Int64) -> Int { + timeZone.secondsFromGMT(for: Date(timeIntervalSince1970: TimeInterval(seconds))) + } + + /// The local calendar date of a whole-second instant. + private func localDate(ofEpochSeconds seconds: Int64) -> LocalCalendarDate { + let localSeconds = seconds + Int64(offsetSeconds(atEpochSeconds: seconds)) + var days = localSeconds / 86_400 + if localSeconds < 0 && localSeconds % 86_400 != 0 { days -= 1 } + return LocalCalendarDate(daysSinceEpoch: Int(days)) + } + + /// The instant at which the zone's offset changes near `localMidnight`, found by bisecting the + /// probe window between the offset before and the offset after. + private func transitionEpochSeconds(around localMidnight: Int64, offsetBefore: Int) -> Int64 { + var low = localMidnight - Self.probeWindow + var high = localMidnight + Self.probeWindow + while high - low > 1 { + let mid = low + (high - low) / 2 + if offsetSeconds(atEpochSeconds: mid) == offsetBefore { + low = mid + } else { + high = mid + } + } + return high + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/PhysiologicalSteps.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/PhysiologicalSteps.swift new file mode 100644 index 0000000000..3527481319 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/PhysiologicalSteps.swift @@ -0,0 +1,120 @@ +import Foundation +import WhoopProtocol + +public enum PhysiologicalSteps { + public enum SleepKind: Sendable { case mainSleep, nap, unclassified } + public struct SleepBlock: Sendable { + public let onset: Int + public let end: Int + public let id: String + public let editedOnset: Int? + public let kind: SleepKind + public var effectiveOnset: Int { editedOnset ?? onset } + public init(onset: Int, end: Int, id: String? = nil, editedOnset: Int? = nil, + kind: SleepKind = .unclassified) { + self.onset = onset; self.end = end; self.id = id ?? String(onset) + self.editedOnset = editedOnset; self.kind = kind + } + } + public struct CycleBoundary: Equatable, Sendable { + public let sleepId: String + public let onset: Int + public init(sleepId: String, onset: Int) { self.sleepId = sleepId; self.onset = onset } + } + public struct CycleWindow: Equatable, Sendable { + public let sleepId: String; public let onset: Int; public let endExclusive: Int + public init(sleepId: String, onset: Int, endExclusive: Int) { + self.sleepId = sleepId; self.onset = onset; self.endExclusive = endExclusive + } + } + public struct OwnerSegment: Equatable, Sendable { + public let owner: String; public let onset: Int; public let endExclusive: Int + public init(owner: String, onset: Int, endExclusive: Int) { + self.owner = owner; self.onset = onset; self.endExclusive = endExclusive + } + } + public struct OwnerCoverage: Equatable, Sendable { + public let owner: String; public let onset: Int; public let endExclusive: Int; public let priority: Int + public init(owner: String, onset: Int, endExclusive: Int, priority: Int) { + self.owner = owner; self.onset = onset; self.endExclusive = endExclusive; self.priority = priority + } + } + private static let minMainSleepSeconds = 3 * 3_600 + + /// Kotlin twin: `PhysiologicalSteps.classifyForCycle`. + public static func classifyForCycle(_ blocks: [SleepBlock], offsetSec: Int, + habitualMidsleepSec: Int?) -> [SleepBlock] { + guard !blocks.isEmpty else { return [] } + let explicit = blocks.indices.filter { blocks[$0].kind == .mainSleep } + let selectable = blocks.indices.filter { blocks[$0].kind != .nap } + let selected: Set + if !explicit.isEmpty { + selected = Set(explicit) + } else { + let nightBlocks = selectable.map { SleepStageTotals.NightBlock(start: blocks[$0].effectiveOnset, + end: blocks[$0].end) } + let eligible = SleepStageTotals.bridgedNightGroups(nightBlocks, offsetSec: offsetSec) + .filter { group in + let total = group.indices.reduce(0) { $0 + max(0, nightBlocks[$1].durationS) } + let onset = group.indices.map { nightBlocks[$0].start }.min() + return total >= minMainSleepSeconds && onset.map { + SleepStageTotals.isOvernightOnset($0, offsetSec: offsetSec) + } == true + } + .flatMap { $0.indices } + let candidates = eligible.map { index in + SleepStageTotals.NightBlock(start: blocks[selectable[index]].effectiveOnset, + end: blocks[selectable[index]].end) + } + let picked = SleepStageTotals.mainNightGroupIndices(candidates, offsetSec: offsetSec, + habitualMidsleepSec: habitualMidsleepSec) ?? [] + selected = Set(picked.map { selectable[eligible[$0]] }) + } + return blocks.indices.map { index in + let block = blocks[index] + return SleepBlock(onset: block.onset, end: block.end, id: block.id, + editedOnset: block.editedOnset, + kind: selected.contains(index) ? .mainSleep : .nap) + } + } + + /// Kotlin twin: `PhysiologicalSteps.cycleWindows`. + public static func cycleWindows(_ boundaries: [CycleBoundary], now: Int) -> [CycleWindow] { + var ids = Set() + let ordered = boundaries.filter { $0.onset <= now && ids.insert($0.sleepId).inserted } + .sorted { $0.onset < $1.onset } + return ordered.indices.compactMap { index in + let end = index + 1 < ordered.count ? ordered[index + 1].onset : now + return end > ordered[index].onset + ? CycleWindow(sleepId: ordered[index].sleepId, onset: ordered[index].onset, endExclusive: end) + : nil + } + } + + /// Kotlin twin: `PhysiologicalSteps.ownerSegmentsFromCoverage`. + public static func ownerSegmentsFromCoverage(_ window: CycleWindow, coverage: [OwnerCoverage], + fallbackOwner: String) -> [OwnerSegment] { + guard window.endExclusive > window.onset else { return [] } + let clipped = coverage.compactMap { item -> OwnerCoverage? in + let start = max(window.onset, item.onset), end = min(window.endExclusive, item.endExclusive) + return end > start ? OwnerCoverage(owner: item.owner, onset: start, + endExclusive: end, priority: item.priority) : nil + } + let seams = Set([window.onset, window.endExclusive] + clipped.flatMap { [$0.onset, $0.endExclusive] }).sorted() + var result: [OwnerSegment] = [], lastOwner = fallbackOwner + for index in 0..<(seams.count - 1) { + let start = seams[index], end = seams[index + 1] + let owner = clipped.filter { start >= $0.onset && start < $0.endExclusive } + .sorted { $0.priority == $1.priority ? $0.owner < $1.owner : $0.priority < $1.priority } + .first?.owner ?? lastOwner + if let last = result.last, last.owner == owner, last.endExclusive == start { + result[result.count - 1] = OwnerSegment(owner: owner, onset: last.onset, endExclusive: end) + } else { + result.append(OwnerSegment(owner: owner, onset: start, endExclusive: end)) + } + lastOwner = owner + } + return result + } + +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/PreSleepHeartRateFeedback.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/PreSleepHeartRateFeedback.swift new file mode 100644 index 0000000000..a486d021d6 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/PreSleepHeartRateFeedback.swift @@ -0,0 +1,285 @@ +import Foundation +import WhoopProtocol +import WhoopStore + +/// An opt-in, descriptive pre-sleep heart-rate reading for the following morning. +/// +/// The caller supplies a timestamp-coalesced HR timeline: Repository's existing HR reads already +/// coalesce measured and PPG-derived samples, with measured data taking precedence. This type selects +/// the longest supplied sleep session, reads the declared half-open window immediately before it, and +/// compares that observation with a personal rolling baseline. It neither diagnoses nor recommends an +/// action. Journal answers are returned only as facts from the same day; one night cannot establish an +/// association, so they never alter the reading or become an insight. +public enum PreSleepHeartRateFeedback { + /// Baseline configuration deliberately shares the existing HR plausibility range while keeping a + /// small floor spread for transparent personal comparison. `rollingMeanSD` is the existing auditable + /// baseline primitive; this is not a new scoring model. + public static let baselineCfg = MetricCfg(minVal: SleepHeartRateContrast.validMinBpm, + maxVal: SleepHeartRateContrast.validMaxBpm, + floorSpread: 2, + halfLifeB: 14, + halfLifeS: 21) + public static let defaultPreSleepWindowSeconds = 30 * 60 + public static let defaultMinimumValidSamples = 10 + public static let minimumBaselineNights = Baselines.minNightsSeed + + /// A prior eligible pre-sleep observation. Callers persist/assemble history; this pure slice does + /// not write, schedule a prompt, or penalize a missing night. + public struct HistoricalReading: Equatable, Sendable { + public let day: String + public let meanBpm: Double + + public init(day: String, meanBpm: Double) { + self.day = day + self.meanBpm = meanBpm + } + } + + /// Why a morning reading is or is not available. Every non-eligible state is recoverable on a + /// later, sufficiently covered night; none creates a streak, goal, or obligation. + public enum Eligibility: Equatable, Sendable { + case disabled + case invalidDay + case invalidWindow + case missingPrimarySleep + case insufficientPreSleepSamples(valid: Int, required: Int) + case insufficientBaseline(validNights: Int, required: Int) + case staleBaseline(daysSinceUpdate: Int) + case eligible + } + + /// The observed, un-imputed pre-sleep data. Completeness is stated as a sample count because raw + /// HR cadence can vary; this slice does not pretend a count is a percentage of time covered. + public struct Observation: Equatable, Sendable { + public let primarySleepStartTs: Int + public let primarySleepEndTs: Int + public let windowStartTs: Int + public let windowEndTs: Int + public let meanBpm: Double + public let validSamples: Int + public let totalTimestampSamples: Int + + public init(primarySleepStartTs: Int, primarySleepEndTs: Int, + windowStartTs: Int, windowEndTs: Int, meanBpm: Double, + validSamples: Int, totalTimestampSamples: Int) { + self.primarySleepStartTs = primarySleepStartTs + self.primarySleepEndTs = primarySleepEndTs + self.windowStartTs = windowStartTs + self.windowEndTs = windowEndTs + self.meanBpm = meanBpm + self.validSamples = validSamples + self.totalTimestampSamples = totalTimestampSamples + } + } + + /// A personal comparison only, never a population norm or a health classification. + public struct Comparison: Equatable, Sendable { + public let baselineBpm: Double + public let deltaBpm: Double + public let baselineNights: Int + public let baselineStatus: BaselineStatus + + public init(baselineBpm: Double, deltaBpm: Double, baselineNights: Int, + baselineStatus: BaselineStatus) { + self.baselineBpm = baselineBpm + self.deltaBpm = deltaBpm + self.baselineNights = baselineNights + self.baselineStatus = baselineStatus + } + } + + /// Explicit limits that a presentation layer must keep visible rather than turning into certainty. + public enum Uncertainty: Equatable, Sendable { + /// The first eligible personal baseline is usable but not yet trusted by `Baselines`. + case provisionalBaseline + /// There are not yet enough valid historical nights to make any personal comparison. + case noPersonalComparison + /// A mature personal baseline has not received a plausible value recently enough to compare. + case staleBaseline(daysSinceUpdate: Int) + } + + /// This slice intentionally makes no causal inference from a single observation or its journal facts. + public enum Inference: Equatable, Sendable { case notEstablished } + /// This slice intentionally cannot support a behavior, treatment, or other recommendation. + public enum Recommendation: Equatable, Sendable { case unsupported } + + /// A projection of a same-day `JournalEntry`. It preserves what was logged without exposing notes or + /// claiming that the entry explains this reading. + public struct JournalFact: Equatable, Sendable { + public let day: String + public let question: String + public let answeredYes: Bool + public let numericValue: Double? + + public init(day: String, question: String, answeredYes: Bool, numericValue: Double?) { + self.day = day + self.question = question + self.answeredYes = answeredYes + self.numericValue = numericValue + } + } + + public struct Feedback: Equatable, Sendable { + public let eligibility: Eligibility + public let observation: Observation? + public let comparison: Comparison? + public let uncertainty: [Uncertainty] + public let inference: Inference + public let recommendation: Recommendation + /// Same-day journal facts only. They are deliberately not treated as a causal insight. + public let journalContext: [JournalFact] + + public init(eligibility: Eligibility, observation: Observation?, comparison: Comparison?, + uncertainty: [Uncertainty], inference: Inference, + recommendation: Recommendation, journalContext: [JournalFact]) { + self.eligibility = eligibility + self.observation = observation + self.comparison = comparison + self.uncertainty = uncertainty + self.inference = inference + self.recommendation = recommendation + self.journalContext = journalContext + } + } + + /// Produce the next-morning reading from existing timestamped HR and sleep primitives. + /// + /// `hr` is expected to be the repository's measured/PPG-coalesced timeline. To remain safe for other + /// callers, duplicate timestamps are also ignored after the first element, preserving the caller's + /// precedence order. Both window bounds are transparent and half-open: `[sleep.start - window, sleep.start)`. + public static func evaluate(enabled: Bool, sessions: [SleepSession], hr: [HRSample], + history: [HistoricalReading], journalEntries: [JournalEntry], day: String, + minimumValidSamples: Int = defaultMinimumValidSamples, + preSleepWindowSeconds: Int = defaultPreSleepWindowSeconds) -> Feedback { + let unsupported = Recommendation.unsupported + let noInference = Inference.notEstablished + guard enabled else { + return Feedback(eligibility: .disabled, observation: nil, comparison: nil, uncertainty: [], + inference: noInference, recommendation: unsupported, journalContext: []) + } + guard let evaluationDate = canonicalDay(day) else { + return Feedback(eligibility: .invalidDay, observation: nil, comparison: nil, uncertainty: [], + inference: noInference, recommendation: unsupported, journalContext: []) + } + guard minimumValidSamples > 0, preSleepWindowSeconds > 0 else { + return Feedback(eligibility: .invalidWindow, observation: nil, comparison: nil, uncertainty: [], + inference: noInference, recommendation: unsupported, journalContext: []) + } + let sessionsWithDuration = sessions.compactMap { session -> (session: SleepSession, duration: Int)? in + let (duration, overflow) = session.end.subtractingReportingOverflow(session.start) + return !overflow && duration > 0 ? (session, duration) : nil + } + guard let primary = sessionsWithDuration.max(by: { $0.duration < $1.duration })?.session else { + return Feedback(eligibility: .missingPrimarySleep, observation: nil, comparison: nil, uncertainty: [], + inference: noInference, recommendation: unsupported, journalContext: []) + } + + let (start, windowOverflow) = primary.start.subtractingReportingOverflow(preSleepWindowSeconds) + guard !windowOverflow else { + return Feedback(eligibility: .invalidWindow, observation: nil, comparison: nil, uncertainty: [], + inference: noInference, recommendation: unsupported, journalContext: []) + } + let inWindow = deduplicated(hr).filter { $0.ts >= start && $0.ts < primary.start } + let valid = inWindow.filter { baselineCfg.minVal <= Double($0.bpm) && Double($0.bpm) <= baselineCfg.maxVal } + guard valid.count >= minimumValidSamples else { + return Feedback(eligibility: .insufficientPreSleepSamples(valid: valid.count, required: minimumValidSamples), + observation: nil, comparison: nil, uncertainty: [], inference: noInference, + recommendation: unsupported, journalContext: []) + } + let mean = Double(valid.reduce(0) { $0 + $1.bpm }) / Double(valid.count) + let observation = Observation(primarySleepStartTs: primary.start, primarySleepEndTs: primary.end, + windowStartTs: start, windowEndTs: primary.start, meanBpm: mean, + validSamples: valid.count, totalTimestampSamples: inWindow.count) + // Baselines use prior nights only and + // `rollingMeanSD` requires oldest-to-newest input. Retain the first caller-supplied reading + // for a repeated canonical day; one day contributes at most one night and no synthetic average + // is invented. + var seenDays = Set() + let priorHistory = history + .compactMap { reading -> (reading: HistoricalReading, date: Date)? in + guard let date = canonicalDay(reading.day), date < evaluationDate, + seenDays.insert(reading.day).inserted else { return nil } + return (reading, date) + } + .sorted { $0.date < $1.date } + let rollingBaseline = Baselines.rollingMeanSD(priorHistory.map(\.reading.meanBpm), cfg: baselineCfg) + let latestContributingDate = priorHistory.last { + baselineCfg.minVal <= $0.reading.meanBpm && $0.reading.meanBpm <= baselineCfg.maxVal + }?.date + let nightsSinceUpdate = latestContributingDate.flatMap { + gregorianUTC.dateComponents([.day], from: $0, to: evaluationDate).day + }.map { max(0, $0) } ?? 0 + let baseline = BaselineState( + baseline: rollingBaseline.baseline, + spread: rollingBaseline.spread, + nValid: rollingBaseline.nValid, + nightsSinceUpdate: nightsSinceUpdate, + status: Baselines.computeStatus(nValid: rollingBaseline.nValid, + nightsSinceUpdate: nightsSinceUpdate) + ) + let context = journalEntries.filter { $0.day == day }.map { + JournalFact(day: $0.day, question: $0.question, answeredYes: $0.answeredYes, + numericValue: $0.numericValue) + } + guard baseline.nValid >= minimumBaselineNights else { + return Feedback(eligibility: .insufficientBaseline(validNights: baseline.nValid, + required: minimumBaselineNights), + observation: observation, comparison: nil, uncertainty: [.noPersonalComparison], + inference: noInference, recommendation: unsupported, journalContext: context) + } + guard baseline.usable else { + return Feedback(eligibility: .staleBaseline(daysSinceUpdate: baseline.nightsSinceUpdate), + observation: observation, comparison: nil, + uncertainty: [.staleBaseline(daysSinceUpdate: baseline.nightsSinceUpdate)], + inference: noInference, recommendation: unsupported, journalContext: context) + } + + let comparison = Comparison(baselineBpm: baseline.baseline, deltaBpm: mean - baseline.baseline, + baselineNights: baseline.nValid, baselineStatus: baseline.status) + let uncertainty: [Uncertainty] = baseline.status == .trusted ? [] : [.provisionalBaseline] + return Feedback(eligibility: .eligible, observation: observation, comparison: comparison, + uncertainty: uncertainty, inference: noInference, recommendation: unsupported, + journalContext: context) + } + + /// Stored, not computed (#1784). `canonicalDay` reads this once per history row, so a computed + /// property built a fresh `Calendar` for every prior night. `Calendar` is a `Sendable` value type, + /// so one shared instance is safe and the construction happens once. + private static let gregorianUTC: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + }() + + private static func canonicalDay(_ day: String) -> Date? { + let bytes = Array(day.utf8) + guard bytes.count == 10, bytes[4] == 45, bytes[7] == 45 else { return nil } + let digitPositions = [0, 1, 2, 3, 5, 6, 8, 9] + guard digitPositions.allSatisfy({ (48...57).contains(bytes[$0]) }) else { return nil } + + let year = Int(bytes[0] - 48) * 1_000 + Int(bytes[1] - 48) * 100 + + Int(bytes[2] - 48) * 10 + Int(bytes[3] - 48) + let month = Int(bytes[5] - 48) * 10 + Int(bytes[6] - 48) + let dayOfMonth = Int(bytes[8] - 48) * 10 + Int(bytes[9] - 48) + let calendar = gregorianUTC + guard let date = calendar.date(from: DateComponents(year: year, month: month, day: dayOfMonth)) else { + return nil + } + let roundTrip = calendar.dateComponents([.era, .year, .month, .day], from: date) + guard roundTrip.era == 1 && roundTrip.year == year && roundTrip.month == month + && roundTrip.day == dayOfMonth else { return nil } + return date + } + + /// One sample per timestamp, FIRST occurrence wins. + /// + /// First-wins is the caller's precedence order, not an arbitrary pick: `evaluate`'s contract is an + /// already-coalesced timeline where measured data outranks PPG-derived, so the first element for a + /// timestamp is the one the repository chose. Stated here as well as on `evaluate` because the rule + /// is invisible at this call site, and a later reader changing it to last-wins would silently invert + /// that precedence (#1784). + private static func deduplicated(_ hr: [HRSample]) -> [HRSample] { + var seen = Set() + return hr.filter { seen.insert($0.ts).inserted } + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/RecoveryScorer.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/RecoveryScorer.swift index 2c411b1f63..78f0070e60 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/RecoveryScorer.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/RecoveryScorer.swift @@ -217,128 +217,6 @@ public enum RecoveryScorer { dampFraction: dampFraction) } - /// Rolling-mean HR window (seconds) for the resting-HR estimate. - public static let restingHRWindowS: Int = 5 * 60 - - /// Minimum HR samples a 5-min bin must hold before its mean is eligible to WIN the resting - /// floor (#686). A thinly-populated bin — at the limit a single lone beat — lets one artifact - /// (a dropout, a decode glitch) become the bin "mean" and win the night's minimum, dragging - /// resting HR implausibly low. Requiring a handful of samples means the floor is a genuine - /// sustained dip, not a one-sample fluke. Worn nights stream ~1 Hz HR so a real 5-min bin holds - /// hundreds of samples and clears this trivially; only sparse/edge bins (a partial trailing bin, - /// a gap-straddling bin) fall below it. Does NOT change the floor DEFINITION — still the min of - /// 5-min bin means — it only stops an under-sampled artifact bin from being that min. - public static let restingHRMinBinSamples: Int = 5 - - /// Physiological resting-HR floor (bpm) below which a bin mean is rejected as a dropout artifact - /// (#686), never the resting floor. An adult's true sleeping resting HR essentially never sits - /// below this; a 5-min mean that does is a run of dropout/decode-zero beats, not a real cardiac - /// dip. 25 bpm clears even deeply-bradycardic trained athletes (resting HRs in the low 30s) with - /// margin while rejecting the implausible artifact range. A bin below this is excluded from floor - /// candidacy; if it were allowed to win, resting HR would read a fabricated sub-physiological value. - public static let restingHRMinPlausibleBpm: Double = 25.0 - - // MARK: - Resting HR - - /// Lowest sustained HR during the in-bed window (bpm, rounded), or nil. - /// - /// "Sustained" = the minimum of 5-minute non-overlapping bin means of the HR - /// samples whose ts ∈ [start, end]. Rejects single-beat dips while capturing - /// the night's true floor. Returns nil when there are no HR samples in window. - /// - /// Artifact hardening (#686): a bin may only WIN the floor when it is BOTH well-populated - /// (≥ `restingHRMinBinSamples`, so one lone artifact beat can't be a bin "mean") AND - /// physiologically plausible (mean ≥ `restingHRMinPlausibleBpm`, rejecting dropout-driven - /// sub-physiological dips). The floor DEFINITION is unchanged — still the minimum of the - /// 5-min bin means — only artifact bins are barred from being that minimum. If no bin - /// qualifies (a wholly sparse/degenerate window), fall back to the lowest of ALL bin means, - /// else the all-sample mean, preserving the never-nil-on-data behaviour. - public static func restingHR(_ hr: [HRSample], start: Int, end: Int) -> Int? { - let seg = hr.filter { $0.ts >= start && $0.ts <= end } - guard !seg.isEmpty else { return nil } - - var means: [Double] = [] // every bin mean (legacy floor, the fallback) - var qualified: [Double] = [] // bins eligible to WIN the floor (#686) - var t = start - while t < end { - let win = seg.filter { $0.ts >= t && $0.ts < t + restingHRWindowS } - if !win.isEmpty { - let mean = Double(win.reduce(0) { $0 + $1.bpm }) / Double(win.count) - means.append(mean) - // A bin wins the floor only if it is well-populated AND physiologically plausible — - // a thin (single-artifact) or sub-physiological (dropout) bin can't be the minimum. - if win.count >= restingHRMinBinSamples && mean >= restingHRMinPlausibleBpm { - qualified.append(mean) - } - } - t += restingHRWindowS - } - let floor: Double - if let m = qualified.min() { - floor = m - } else if let m = means.min() { - // No bin cleared the artifact bar (sparse window): fall back to the legacy floor. - floor = m - } else { - floor = Double(seg.reduce(0) { $0 + $1.bpm }) / Double(seg.count) - } - return Int(floor.rounded()) - } - - // MARK: - Recovery Index (overnight HR-decline slope) - - /// Minimum 5-minute bins (`restingHR`'s SAME binning) required before a slope is trusted — - /// below this, too little of the night has elapsed to fit a trend, and a 1-2-point - /// regression is noise, not a night-long pattern. 6 bins = 30 minutes of binned coverage, - /// a deliberately low floor so a short/partial night still gets a number rather than a - /// routine nil. - public static let recoveryIndexMinBins: Int = 6 - - /// Overnight resting-HR DECLINE slope (bpm/hour) across the in-bed window — the "Recovery - /// Index" component of Oura's Readiness that Charge lacked (it previously only read the - /// overnight FLOOR via `restingHR` above, never the trend that reaches it). - /// - /// Computed as the least-squares slope of the SAME non-overlapping 5-minute HR bin means - /// `restingHR` uses (`restingHRWindowS`) against each bin's midpoint time (hours from - /// `start`). NEGATIVE = declining (HR falling through the night — the physiologically - /// expected, good pattern); POSITIVE = rising (restlessness, illness, alcohol, a late - /// stimulant). Returns nil when fewer than `recoveryIndexMinBins` bins have data (too - /// little of the window to fit a trend) or there are no samples at all — it never - /// fabricates a slope from a sliver of the night. - public static func recoveryIndexSlope(_ hr: [HRSample], start: Int, end: Int) -> Double? { - let seg = hr.filter { $0.ts >= start && $0.ts <= end } - guard !seg.isEmpty else { return nil } - - // Same non-overlapping 5-minute binning as restingHR: both read the identical - // underlying series, one as a floor, one as a trend across it. - var points: [(tHours: Double, meanBpm: Double)] = [] - var t = start - while t < end { - let win = seg.filter { $0.ts >= t && $0.ts < t + restingHRWindowS } - if !win.isEmpty { - let mean = Double(win.reduce(0) { $0 + $1.bpm }) / Double(win.count) - let midpointS = Double(t - start) + Double(restingHRWindowS) / 2.0 - points.append((tHours: midpointS / 3600.0, meanBpm: mean)) - } - t += restingHRWindowS - } - guard points.count >= recoveryIndexMinBins else { return nil } - - // Least-squares slope: Σ((t-t̄)(y-ȳ)) / Σ((t-t̄)²), bpm per hour. - let n = Double(points.count) - let tBar = points.reduce(0.0) { $0 + $1.tHours } / n - let yBar = points.reduce(0.0) { $0 + $1.meanBpm } / n - var num = 0.0, den = 0.0 - for p in points { - let dt = p.tHours - tBar - num += dt * (p.meanBpm - yBar) - den += dt * dt - } - // Degenerate (all bins at the same instant): no time spread to fit against. - guard den > 1e-9 else { return 0.0 } - return num / den - } - // MARK: - Recovery band /// WHOOP-style color band for a recovery score [0, 100]. @@ -422,8 +300,8 @@ public enum RecoveryScorer { /// no-skin-temp score is identical to before. /// - hrvBaselineUsable: whether the HRV baseline has enough nights /// (BaselineState.usable). When false, returns nil (cold-start). - /// - recoveryIndexSlope: overnight resting-HR DECLINE slope (bpm/hour, from - /// `recoveryIndexSlope(_:start:end:)`). Negative (declining) supports recovery; + /// - recoveryIndexSlope: overnight resting-HR DECLINE slope (bpm/hour), supplied by the + /// caller. Negative (declining) supports recovery; /// positive (rising) limits it, weight wRecoveryIndex. nil (the default) drops the /// term and the weights renormalize, so the no-slope score is IDENTICAL to before /// this parameter existed. diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/RrEmissionStats.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/RrEmissionStats.swift index 5456fe079c..0d3fef9edc 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/RrEmissionStats.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/RrEmissionStats.swift @@ -127,6 +127,34 @@ public enum RrEmissionStats { spanSec: span, ratio: ratio, perSecond: hist, gapHist: gapHist, fill: fill) } + /// 15 minutes: rare enough not to bury the log, frequent enough to sample a night several times. + public static let liveCensusMinGapSec: Int = 900 + + /// Should a LIVE census line be emitted now? (#1118 instrumentation.) + /// + /// The historical census is emitted once per offload SESSION, which is naturally rare. The two live + /// paths flush roughly every 30 readings, so emitting per flush would put a line on the strap log + /// every ~30 seconds all night — a volume that buries the very evidence it exists to provide. One + /// line per path per `minGapSec` is plenty: a single flush already spans enough seconds to make + /// `ratioRep` meaningful (the historical line settles it on 39 s), and what matters is comparing the + /// paths, not sampling either densely. + /// + /// `lastEmitSec` is 0 before the first line, so the first flush of a connection always reports. + /// A clock that moves BACKWARDS emits rather than suppresses: the alternative wedges the census shut + /// until wall time catches up, and a silent instrument is worse than a duplicated line. + /// + /// Byte-parity twin of Kotlin `RrEmissionStats.shouldEmitLiveCensus`. + public static func shouldEmitLiveCensus(lastEmitSec: Int, nowSec: Int, + minGapSec: Int = liveCensusMinGapSec) -> Bool { + // The "never emitted" sentinel is checked EXPLICITLY. Falling through to the gap arithmetic + // gets the right answer in production only because a real unix `nowSec` is ~1.8e9 and so + // trivially clears any gap — an accident of magnitude, not a rule. It also made the helper + // untestable with small timestamps, which is how this was caught. + if lastEmitSec <= 0 { return true } + if nowSec < lastEmitSec { return true } + return nowSec - lastEmitSec >= minGapSec + } + /// One compact log line. `offered`/`inserted` come from the caller: `inserted` is what the store /// actually wrote after its `ON CONFLICT` key, so `offered - inserted` is how much the primary key /// already absorbs — the third number needed to tell emission from ingest. @@ -140,11 +168,16 @@ public enum RrEmissionStats { /// gapless, and `ratioRep` is the one to trust when they disagree. (`ratioRep`'s denominator can /// double-count at most one second per chunk boundary when a session's counts are summed, which is /// negligible against thousands of seconds — and errs the same safe way, low.) - public static func logLine(path: String, offered: Int, inserted: Int, _ r: Result) -> String { + public static func logLine(path: String, offered: Int, inserted: Int?, _ r: Result) -> String { + // NIL renders "n/a", never a number. Only the historical path can see what the store's conflict + // key actually kept; the live paths census BEFORE the insert and have no such count. Echoing + // `offered` there would print `offered=N inserted=N`, which reads as "the primary key absorbed + // nothing" — a measurement neither path made. Same reason GpsSession passes rawFixes = nil. + let ins = inserted.map(String.init) ?? "n/a" let ratio = String(format: "%.2f", r.ratio) let rep = r.secondsWithRr > 0 ? Double(r.sumRrMs) / 1000.0 / Double(r.secondsWithRr) : 0 let h = r.perSecond - return "rr emit path=\(path) offered=\(offered) inserted=\(inserted) secs=\(r.secondsWithRr) " + return "rr emit path=\(path) offered=\(offered) inserted=\(ins) secs=\(r.secondsWithRr) " + "sumRr=\(r.sumRrMs / 1000)s span=\(r.spanSec)s ratio=\(ratio) " + "ratioRep=\(String(format: "%.2f", rep)) " + "perSec[1/2/3/4+]=\(h[0])/\(h[1])/\(h[2])/\(h[3]) " diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/ScoreConfidence.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/ScoreConfidence.swift index 6c7b2f0217..b5d5978e42 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/ScoreConfidence.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/ScoreConfidence.swift @@ -1,4 +1,5 @@ import Foundation +import WhoopStore // ScoreConfidence.swift — per-score certainty tier for Charge / Effort / Rest. // @@ -78,25 +79,39 @@ public enum ScoreConfidence: String, Equatable, Sendable, Codable { /// suspicious case — high efficiency (lots of measured sleep) but implausibly little restorative. public static let highEfficiencyThreshold: Double = 0.85 - /// Rest confidence WITH the H9 stage-quality check AND the sparse-motion guard. Starts from - /// `rest(hasSession:hasStagedSleep:)`, then DOWNGRADES a `.solid` tier to `.building` (low-confidence) - /// when EITHER: + /// Rest confidence WITH the H9 stage-quality check, the sparse-motion guard AND the hypnogram-coverage + /// guard. Starts from `rest(hasSession:hasStagedSleep:)`, then DOWNGRADES a `.solid` tier to `.building` + /// (low-confidence) when ANY of: /// - the night was staged on SPARSE gravity (`gravitySparse`) — a WHOOP 4.0 synced/offload night banks /// motion coarsely, too sparse to reliably stage sleep (#345), so a confident 85–100 Rest is unearned /// however the engine filled the stages. This catches the case H9 MISSES: a sparse night whose staging /// manufactures HIGH efficiency AND HIGH restorative reads SOLID under H9 alone (the #319 signature), /// yet the underlying data can't support it; OR /// - the night is high-efficiency yet its restorative (deep+REM) share is below - /// `restorativeLowConfidenceShare` — a likely staging miss (#H9). + /// `restorativeLowConfidenceShare` — a likely staging miss (#H9); OR + /// - `stageCoverage` says the stage timeline accounts for less than `HypnogramCoverage.minCoverage` of + /// the span it claims. This is the case the other two structurally cannot see: a device-PROVIDED + /// hypnogram assembled from records that arrived incomplete has real stages over the part that DID + /// arrive, so its restorative share is ordinary and H9 stays quiet, while `gravitySparse` describes + /// the on-device motion stager and is false for a provided hypnogram (and inert for Oura outright, + /// which banks no gravity at all). Measured: a ring night covering 23% of its 601-minute span was + /// stored as 70 minutes of sleep and reported SOLID. /// `asleepSeconds`/`restorativeSeconds` are the night's totals; efficiency is asleep/in-bed in [0,1]. + /// `stageCoverage` is nil when coverage is unknown or not applicable — the guard fails OPEN there, so + /// an unmeasurable payload keeps its previous tier rather than being downgraded on no evidence. /// `.calibrating`/`.building` from the base call are returned unchanged. Confidence-only — never changes - /// the Rest score or invents stages. Engine output only; the UI surfaces the tier later. (#H9, #345) + /// the Rest score, invents stages, or claims the uncovered time was awake. Engine output only; the UI + /// surfaces the tier later. (#H9, #345) public static func rest(hasSession: Bool, hasStagedSleep: Bool, asleepSeconds: Double, restorativeSeconds: Double, - efficiency: Double, gravitySparse: Bool = false) -> ScoreConfidence { + efficiency: Double, gravitySparse: Bool = false, + stageCoverage: Double? = nil) -> ScoreConfidence { let base = rest(hasSession: hasSession, hasStagedSleep: hasStagedSleep) if base != .solid { return base } if gravitySparse { return .building } // #345: sparse-motion staging can't earn a SOLID Rest + if let c = stageCoverage, c < HypnogramCoverage.minCoverage { + return .building // the timeline covers only part of the night it claims + } if asleepSeconds <= 0 { return base } let restorativeShare = restorativeSeconds / asleepSeconds if efficiency >= highEfficiencyThreshold && restorativeShare < restorativeLowConfidenceShare { diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SkinTempBackfill.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SkinTempBackfill.swift new file mode 100644 index 0000000000..61f29be71c --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SkinTempBackfill.swift @@ -0,0 +1,159 @@ +import Foundation +import WhoopProtocol + +/// #1853: backfill the nightly ABSOLUTE skin temperature (`skinTempC`) for nights the 21-night +/// `analyzeRecent` rescore window never reaches. +/// +/// `skinTempC` is written only by the scoring pass (`IntelligenceEngine`, #1663, 2026-08-27), which +/// covers a rolling 21-night window. Nights already outside that window when the column shipped were +/// never revisited, and nothing walks them since — so a night is missing its absolute if it fell +/// outside every window that ran after 27 Aug, regardless of whether its raw `skinTempSample` rows +/// are still on disk (they are: `skinTempSample` has no age-based retention). +/// +/// This file holds the PURE, database-free half of the backfill: the session-attribution rule and the +/// per-night plan. The walker that reads from / writes to `WhoopStore` lives in `Strand/Data`. +/// +/// Three correctness rules (all re-review findings from the Android twin, #1852 — reproduced verbatim +/// or the Swift backfill will be wrong in the same ways): +/// +/// 1. **Attribute sessions by END.** The night window is 54 h wide (30 h before local midnight through +/// the next), so it contains the PREVIOUS night as well. The engine's rule is +/// `matched = allSessions.filter { tsInDay(it.end) }`; handing the funnel the whole window averages +/// two nights into one temperature and stores it as the wrong day's. +/// +/// 2. **Page the candidates.** A plain "oldest N" returns the same nights every pass, and the oldest +/// are the ones most likely to have lost their raw samples — the sweep latches on page one and never +/// reaches the newer nights that can fill. +/// +/// 3. **The WHOOP 4.0 anchor comes from the CURRENT window.** It is learned window-wide, and the +/// engine's note says that is safe because the offset "cancels in the deviation" — true for a delta, +/// not for an absolute. A re-learned anchor puts backfilled nights on a different offset from the +/// stored ones and the chart plots two scales as one line. **No anchor ⇒ decline, never the global +/// default.** +public enum SkinTempBackfill { + + /// One night the backfill is considering: the day key, the night's read window (from/to in unix + /// seconds), and the device owner whose raw streams should be read. The walker builds these from + /// `DailyMetric` rows that have a `skinTempDevC` but no `skinTempC`; the pure plan below decides + /// which sessions and samples belong to each. + public struct NightCandidate: Equatable, Sendable { + public let day: String // yyyy-MM-dd (local-day key, matching DailyMetric.day) + public let from: Int // unix seconds: dayStart - StreamReadCap.lookbackSeconds + public let to: Int // unix seconds: dayStart + 24h (or now for today) + public let owner: String // the device id whose raw streams wrote this night + public init(day: String, from: Int, to: Int, owner: String) { + self.day = day; self.from = from; self.to = to; self.owner = owner + } + } + + /// The result of attempting one night's backfill. + public struct NightResult: Equatable, Sendable { + public let day: String + /// The re-derived absolute (°C), or nil when the night declined (no anchor, too few worn + /// samples, no sessions, etc.). The walker writes `skinTempC` ONLY when this is non-nil. + public let skinTempC: Double? + /// Why the night declined, for the diagnostic report. nil when it filled. + public let declineReason: String? + public init(day: String, skinTempC: Double?, declineReason: String? = nil) { + self.day = day; self.skinTempC = skinTempC; self.declineReason = declineReason + } + } + + /// Rule 1: attribute sessions by END timestamp. The night window is 54 h wide and contains the + /// PREVIOUS night as well, so handing the funnel every session in the window averages two nights + /// into one temperature. The engine's rule is `matched = allSessions.filter { tsInDay(it.end) }`: + /// a session belongs to `day` iff its END falls in `[dayStart, dayStart + 86400)`. + /// + /// `dayStart` is the LOCAL-midnight unix seconds for the day key (the same `dayStart` the engine's + /// per-day loop computes). `sessions` are the raw `SleepSession`s read from the 54 h window. + /// Pure + deterministic; the walker tests this directly. + public static func sessionsForDay(_ sessions: [SleepSession], dayStart: Int) -> [SleepSession] { + let dayEnd = dayStart + 86_400 + return sessions.filter { $0.end >= dayStart && $0.end < dayEnd } + } + + /// Rule 3: the WHOOP 4.0 anchor MUST come from the current scoring window. If no per-device anchor + /// could be learned (too few in-band samples), DECLINE the night — do NOT fall back to the global + /// 826 anchor. A re-learned anchor puts backfilled nights on a different offset from the stored + /// ones and the chart plots two scales as one line. + /// + /// Returns the anchor to use, or nil to decline. For a 5/MG (`.whoop5`) the anchor is always nil + /// and the night proceeds (centidegree path, no anchor). + public static func resolveAnchor(family: DeviceFamily, windowAnchorRaw: Double?) -> Double? { + switch family { + case .whoop4: + // No anchor ⇒ decline. The global 826 is NOT a fallback here (rule 3). + return windowAnchorRaw + case .whoop5: + // Centidegree path: no anchor, no decline on anchor grounds. + return nil + } + } + + /// Compute one night's backfill absolute, applying all three rules. Pure + deterministic; the + /// walker supplies the raw inputs and writes the result. + /// + /// - Parameters: + /// - candidate: the night to backfill (day, window, owner). + /// - sessions: ALL sleep sessions in the 54 h window (the walker reads them once); rule 1 + /// filters to this day's by end timestamp. + /// - hr: HR samples for the night's window. + /// - skinTemp: raw skin-temp samples for the night's window. + /// - family: the device family that wrote the owner's skin-temp rows (`.whoop4` vs `.whoop5`). + /// - windowAnchorRaw: the per-device WHOOP 4.0 anchor learned from the CURRENT scoring window + /// (rule 3). nil for a 5/MG or when the current window couldn't learn one. + /// - dayStart: the LOCAL-midnight unix seconds for `candidate.day`. + /// - wornToleranceSec: the worn-gate timestamp tolerance (0 for WHOOP, >0 for Oura). + /// - Returns: the nightly mean (°C) or nil with a decline reason. + public static func computeNight( + candidate: NightCandidate, + sessions: [SleepSession], + hr: [HRSample], + skinTemp: [SkinTempSample], + family: DeviceFamily, + windowAnchorRaw: Double?, + dayStart: Int, + wornToleranceSec: Int = 0 + ) -> NightResult { + // Rule 1: attribute sessions by END. Don't hand the funnel the whole 54 h window. + let matched = sessionsForDay(sessions, dayStart: dayStart) + if matched.isEmpty { + return NightResult(day: candidate.day, skinTempC: nil, + declineReason: "no sleep session ends on this day") + } + // Rule 3: the WHOOP 4.0 anchor comes from the current window. No anchor ⇒ decline. + let anchor = resolveAnchor(family: family, windowAnchorRaw: windowAnchorRaw) + if family == .whoop4 && anchor == nil { + return NightResult(day: candidate.day, skinTempC: nil, + declineReason: "no per-device 4.0 anchor from the current window (rule 3: decline, not global fallback)") + } + // Run the SAME funnel the scoring pass uses — identical inputs, identical result, no second + // derivation that could disagree. + let diag = AnalyticsEngine.skinTempFunnel( + matched, hr: hr, skinTemp: skinTemp, family: family, + anchorRaw: anchor, wornToleranceSec: wornToleranceSec) + if let mean = diag.mean { + return NightResult(day: candidate.day, skinTempC: mean) + } + // Decline with the funnel's own explanation so the diagnostic report is self-attesting. + let reason = diag.kept < diag.minSamples + ? "only \(diag.kept)/\(diag.minSamples) worn samples" + : "funnel returned no mean" + return NightResult(day: candidate.day, skinTempC: nil, declineReason: reason) + } + + /// Rule 2: page the candidates so the sweep doesn't latch on the oldest N (which are the most + /// likely to have lost their raw samples). Returns the candidates for one page, oldest-first + /// within the page. The walker calls this repeatedly with increasing `page` (0-based) until a page + /// comes back empty. + /// + /// `pageSize` caps the per-pass cost (one sleep-window sample read per night). The issue's open + /// question on cost is answered by keeping this modest (default 50) and letting the caller decide + /// how many pages to run. + public static func page(_ candidates: [NightCandidate], page: Int, pageSize: Int = 50) -> [NightCandidate] { + let start = page * pageSize + guard start < candidates.count else { return [] } + let end = min(start + pageSize, candidates.count) + return Array(candidates[start.. Kind? { + values.last.map(kind(of:)) + } + + /// A resolved reading: the number to show and the scale it is on. + public struct Reading: Sendable, Equatable { + public let value: Double + public let kind: Kind + public init(value: Double, kind: Kind) { self.value = value; self.kind = kind } + } + + /// Which of a night's two skin-temp numbers a surface should LEAD with (#1844), the pure form of the + /// rule the Health tile has applied since #1665. + /// + /// The absolute wins whenever the night measured one, because a deviation with no anchor cannot be + /// read — "+0.9" is a fever or a warm bedroom and nothing on a card says which. The deviation is the + /// fallback, not the default, and keeps its Δ unit so it is never mistaken for a wrist temperature + /// (#622). + /// + /// Two shapes make this more than a preference, both real rows: + /// - a night scored before `skinTempC` shipped (2026-08-27) has only a deviation, and keeps exactly + /// the display that shipped before until a scoring pass refills it; + /// - a CALIBRATING night is the reverse — the deviation is nil until the baseline is usable + /// (~4 nights) while the absolute is measured from night one, so those wearers otherwise see an + /// empty card with a real temperature sitting behind it. + /// + /// `prefer` is the user's Settings choice (#1846): `.absolute` — the default, a temperature — or + /// `.deviation` for wearers who read the ±baseline move, which is the more sensitive illness signal. It + /// picks which number is tried FIRST; the other is still the fallback, so choosing one never blanks a + /// card whose night only measured the other, and the unit always names the scale actually shown. + /// + /// Both values must come from the SAME row; see `DailyMetric.lastSkinTempReadingDay`. Twin of the + /// Kotlin `SkinTempDisplay.leadReading`. + public static func leadReading(absC: Double?, devC: Double?, prefer: Kind = .absolute) -> Reading? { + let first = prefer == .absolute ? absC : devC + if let f = first { return Reading(value: f, kind: prefer) } + let other = prefer == .absolute ? devC : absC + let otherKind: Kind = prefer == .absolute ? .deviation : .absolute + if let o = other { return Reading(value: o, kind: otherKind) } + return nil + } + + /// Formats whichever number `leadReading` chose, with the unit for THAT scale. + public static func formatReading(_ reading: Reading, fahrenheit: Bool, decimals: Int = 1) -> String { + let n = numberString(reading.value, kind: reading.kind, fahrenheit: fahrenheit, decimals: decimals) + return "\(n) \(unitSymbol(kind: reading.kind, fahrenheit: fahrenheit))" + } + /// Trailing unit chip: `"°C"` / `"°F"` for absolute, `"Δ°C"` / `"Δ°F"` for deviation. public static func unitSymbol(kind: Kind, fahrenheit: Bool) -> String { let base = fahrenheit ? "°F" : "°C" @@ -48,7 +112,13 @@ public enum SkinTempDisplay { if kind == .absolute { return String(format: "%.\(decimals)f", display) } - return String(format: "%+.\(decimals)f", display) + let signed = String(format: "%+.\(decimals)f", display) + // #1842: a deviation that ROUNDS to zero must not keep its sign. `%+` prints "-0.0" for anything + // in (-0.05, 0), and "-0.0 Δ°C" on a card reads as a fault rather than as "no change from your + // baseline" — which is what it means. Drop the sign only when the rounded value is zero; a real + // -0.1 keeps it. Twin of the Kotlin numberString. + if Double(signed.dropFirst()) == 0 { return String(signed.dropFirst()) } + return signed } /// Full `"34.2 °C"` / `"+0.1 Δ°C"` string for one-shot call sites (Today cards, explorers). diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepAwareStepCounter.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepAwareStepCounter.swift new file mode 100644 index 0000000000..fbe95a4025 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepAwareStepCounter.swift @@ -0,0 +1,137 @@ +import Foundation +import WhoopProtocol + +/// Sleep-context twin of Kotlin `SleepAwareStepCounter`. Counter decisions stay orientation-independent. +public enum SleepAwareStepCounter { + public static let maxSecondsBetweenGaitDeltas = 3 + public static let minGaitBoutDurationSeconds = 4 + public static let minGaitBoutActiveSamples = 5 + public static let minGaitBoutTicks = 6 + + public struct Count: Equatable, Sendable { + public let totalTicks: Int + public let acceptedOutsideSleepTicks: Int + public let acceptedAwakeGapTicks: Int + public let acceptedSleepBoutTicks: Int + public let rejectedIsolatedSleepTicks: Int + public let rejectedActivityClassTicks: Int + public let rejectedImplausibleTicks: Int + public let gravitySamplesAvailable: Int + public let auxSamplesAvailable: Int + + public static let empty = Count(totalTicks: 0, acceptedOutsideSleepTicks: 0, + acceptedAwakeGapTicks: 0, acceptedSleepBoutTicks: 0, rejectedIsolatedSleepTicks: 0, + rejectedActivityClassTicks: 0, rejectedImplausibleTicks: 0, + gravitySamplesAvailable: 0, auxSamplesAvailable: 0) + + /// Kotlin twin: `SleepAwareStepCounter.Count.plus`. + public func adding(_ other: Count) -> Count { + Count(totalTicks: totalTicks + other.totalTicks, + acceptedOutsideSleepTicks: acceptedOutsideSleepTicks + other.acceptedOutsideSleepTicks, + acceptedAwakeGapTicks: acceptedAwakeGapTicks + other.acceptedAwakeGapTicks, + acceptedSleepBoutTicks: acceptedSleepBoutTicks + other.acceptedSleepBoutTicks, + rejectedIsolatedSleepTicks: rejectedIsolatedSleepTicks + other.rejectedIsolatedSleepTicks, + rejectedActivityClassTicks: rejectedActivityClassTicks + other.rejectedActivityClassTicks, + rejectedImplausibleTicks: rejectedImplausibleTicks + other.rejectedImplausibleTicks, + gravitySamplesAvailable: gravitySamplesAvailable + other.gravitySamplesAvailable, + auxSamplesAvailable: auxSamplesAvailable + other.auxSamplesAvailable) + } + } + + /// Stateful, overlap-safe counter for database windows read in ascending pages. + public final class Accumulator { + private let sessions: [SleepSession] + private let hasClasses: Bool + private var previous: StepSample? + private var outside = 0, awake = 0, sleep = 0, rejectedSleep = 0 + private var rejectedClass = 0, rejectedImplausible = 0 + private var gravityAvailable = 0, auxAvailable = 0 + private var pending: [(ts: Int, ticks: Int)] = [] + private var finished = false + + public init(sleepSessions: [SleepSession], hasActivityClasses: Bool) { + sessions = sleepSessions.sorted { $0.start < $1.start } + hasClasses = hasActivityClasses + } + + /// Kotlin twin: `SleepAwareStepCounter.Accumulator.acceptPage`. + @discardableResult public func acceptPage(_ samples: [StepSample]) -> Accumulator { + precondition(!finished) + for current in samples.sorted(by: { $0.ts < $1.ts }) { + guard let prior = previous else { previous = current; continue } + guard current.ts > prior.ts else { continue } + previous = current + let delta = (current.counter - prior.counter) & 0xffff + guard StepsCounter.shouldCountDelta(activityClass: current.activityClass, + hasActivityClasses: hasClasses) else { + rejectedClass += delta; continue + } + guard StepsCounter.isPlausibleDelta(previousTs: prior.ts, currentTs: current.ts, + delta: delta) else { + rejectedImplausible += delta; continue + } + switch SleepAwareStepCounter.context(current.ts, sessions: sessions) { + case 0: flush(); outside += delta + case 1: flush(); awake += delta + default: + if let last = pending.last, current.ts - last.ts > maxSecondsBetweenGaitDeltas { flush() } + pending.append((current.ts, delta)) + } + } + return self + } + + /// Kotlin twin: `SleepAwareStepCounter.Accumulator.observeMotionPage`. + @discardableResult public func observeMotion(gravityCount: Int, auxCount: Int) -> Accumulator { + precondition(!finished) + gravityAvailable += gravityCount + auxAvailable += auxCount + return self + } + + /// Kotlin twin: `SleepAwareStepCounter.Accumulator.finish`. + public func finish() -> Count { + if !finished { flush(); finished = true } + return Count(totalTicks: outside + awake + sleep, acceptedOutsideSleepTicks: outside, + acceptedAwakeGapTicks: awake, acceptedSleepBoutTicks: sleep, + rejectedIsolatedSleepTicks: rejectedSleep, + rejectedActivityClassTicks: rejectedClass, + rejectedImplausibleTicks: rejectedImplausible, + gravitySamplesAvailable: gravityAvailable, + auxSamplesAvailable: auxAvailable) + } + + /// Kotlin twin: `SleepAwareStepCounter.Accumulator.flushSleepBout`. + private func flush() { + guard !pending.isEmpty else { return } + let ticks = pending.reduce(0) { $0 + $1.ticks } + let duration = pending.last!.ts - pending.first!.ts + let coherent = pending.count >= minGaitBoutActiveSamples + && duration >= minGaitBoutDurationSeconds && ticks >= minGaitBoutTicks + if coherent { sleep += ticks } else { rejectedSleep += ticks } + pending.removeAll(keepingCapacity: true) + } + } + + /// Kotlin twin: `SleepAwareStepCounter.stepsInWindow`. + public static func stepsInWindow(_ samples: [StepSample], sleepSessions: [SleepSession]) -> Int? { + let count = count(samples, sleepSessions: sleepSessions) + return count.totalTicks > 0 ? count.totalTicks : nil + } + + /// Kotlin twin: `SleepAwareStepCounter.count`. + public static func count(_ samples: [StepSample], sleepSessions: [SleepSession]) -> Count { + let sorted = samples.sorted { $0.ts < $1.ts } + return Accumulator(sleepSessions: sleepSessions, + hasActivityClasses: StepsCounter.hasActivityClasses(sorted)) + .acceptPage(sorted).finish() + } + + /// Kotlin twin: `SleepAwareStepCounter.contextAt`. + private static func context(_ ts: Int, sessions: [SleepSession]) -> Int { + guard let session = sessions.first(where: { ts >= $0.start && ts < $0.end }) else { return 0 } + if let stage = session.stages.first(where: { ts >= $0.start && ts < $0.end }), + SleepStageVocabulary.isWake(stage.stage) { return 1 } + return 2 + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepDebt.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepDebt.swift index 6d711dca63..d4dbae179d 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepDebt.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepDebt.swift @@ -1,17 +1,16 @@ import Foundation -// SleepDebt.swift — a rolling sleep-debt ledger over the last N nights. +// SleepDebt.swift — a recency-weighted sleep-debt estimate over the last N nights. // // Pure, deterministic, DB-free. Given a chronological series of per-night total // sleep (minutes) and a personal sleep need (hours), it accumulates a running -// balance of (actual − need) per night across a capped trailing window (14 nights -// by default) and reports the net balance plus the per-night deltas that make it -// up. +// debt estimate across a capped trailing window (14 nights by default). The +// previous estimate is part of the following night's need; 55% of any unmet need +// carries forward. Tiny, non-actionable results below ten minutes are cleared. // // HONEST by construction: -// - It is a plain debt accumulator — sum of nightly (slept − need) — NOT a -// physiological model. A surplus night (slept > need) genuinely offsets a -// deficit one, the same way a checking balance nets credits and debits. +// - It is a planning estimate, not a physiological claim. Meeting base need plus +// the current estimate clears the debt; extra sleep never creates a surplus. // - The window is capped (default 14) so "debt" never compounds indefinitely // across months of history — only the recent fortnight is in scope. // - Nights with no usable sleep total are SKIPPED entirely (no zero-fill), so a @@ -38,10 +37,10 @@ public struct SleepDebtNight: Equatable, Sendable { } } -/// The rolling sleep-debt ledger over the capped trailing window. +/// The sleep-debt estimate over the capped trailing window. public struct SleepDebtLedger: Equatable, Sendable { - /// Net running balance (minutes) across the window: Σ(slept − need). Negative = - /// net DEBT (under-slept overall), positive = net SURPLUS, 0 = on target. + /// Current estimate in the existing signed UI convention: negative = debt, + /// zero = on target. This model never reports a positive surplus. public let balanceMin: Double /// Per-night contributions, oldest → newest, one per counted night (skipped /// nights are absent). The `deltaMin` values are the per-night bar/spark. @@ -67,9 +66,15 @@ public enum SleepDebt { /// short enough that one rough patch doesn't read as months of compounding debt. public static let defaultWindowNights: Int = 14 + /// Share of the complete unmet need carried into the following night. + public static let debtCarryFactor: Double = 0.55 + + /// Calculated debts smaller than this are too small to be actionable. + public static let minimumDebtMin: Double = 10.0 + /// "On target" deadband (minutes): a |balance| under this reads as balanced rather /// than as a debt/surplus, so a few stray minutes don't flip the headline. - public static let onTargetBandMin: Double = 30.0 + public static let onTargetBandMin: Double = minimumDebtMin /// Sleep credited toward debt for one day. `mainSleepMin` remains the canonical /// main-night total used by Rest and the sleep headline; separately-recorded naps @@ -84,6 +89,41 @@ public enum SleepDebt { return mainSleepMin + max(napSleepMin, 0) } + /// Debt values oldest → newest for local fallback surfaces. The returned history is + /// not capped: each usable day's local value is independently recomputed from the + /// trailing `window` usable nights. An imported value wins verbatim for that day's + /// output only; it never seeds or replaces the local recurrence. Imported-only rows + /// are emitted but do not consume a usable-night slot. Rows with neither usable sleep + /// nor an imported value have no debt observation and are omitted. + public static func debtSeries( + series: [(day: String, totalSleepMin: Double?)], + needHours: Double = AnalyticsEngine.Rest.defaultNeedHours, + importedDebtMin: [String: Double] = [:], + window: Int = defaultWindowNights + ) -> [(day: String, value: Double)] { + let cap = max(window, 1) + var usableHistory: [(day: String, totalSleepMin: Double?)] = [] + usableHistory.reserveCapacity(cap) + var result: [(day: String, value: Double)] = [] + result.reserveCapacity(series.count) + + for row in series { + let sleptMin = row.totalSleepMin.flatMap { $0 > 0 ? $0 : nil } + if let sleptMin { + usableHistory.append((row.day, sleptMin)) + if usableHistory.count > cap { usableHistory.removeFirst() } + } + + if let imported = importedDebtMin[row.day] { + result.append((row.day, imported)) + } else if sleptMin != nil { + let debt = ledger(series: usableHistory, needHours: needHours, window: cap).magnitudeMin + result.append((row.day, debt)) + } + } + return result + } + /// Build the ledger from a chronological `[(day, totalSleepMin?)]` series. /// /// - Parameters: @@ -97,9 +137,9 @@ public enum SleepDebt { /// - window: how many of the most-recent COUNTED nights to include. Defaults to /// `defaultWindowNights` (14). Clamped to ≥ 1. /// - /// The balance is Σ over the window of (sleptMin − needMin): a surplus night - /// offsets a deficit one. Returns an empty ledger (balance 0, no nights) when no - /// night has usable data. + /// For each retained night, `nextDebt = 0.55 × max(0, need + debt − slept)`. + /// Results below ten minutes clear to zero. `SleepDebtNight.deltaMin` deliberately + /// remains the raw `slept − base need` value used by the per-night bars. public static func ledger(series: [(day: String, totalSleepMin: Double?)], needHours: Double = AnalyticsEngine.Rest.defaultNeedHours, window: Int = defaultWindowNights) -> SleepDebtLedger { @@ -113,14 +153,15 @@ public enum SleepDebt { var nights: [SleepDebtNight] = [] nights.reserveCapacity(windowed.count) - var balance = 0.0 + var debt = 0.0 for row in windowed { let slept = row.totalSleepMin ?? 0 let delta = slept - needMin - balance += delta + let calculatedDebt = debtCarryFactor * max(0, needMin + debt - slept) + debt = calculatedDebt < minimumDebtMin ? 0 : calculatedDebt nights.append(SleepDebtNight(day: row.day, sleptMin: slept, deltaMin: delta)) } - return SleepDebtLedger(balanceMin: round1(balance), nights: nights, needMin: needMin) + return SleepDebtLedger(balanceMin: round1(-debt), nights: nights, needMin: needMin) } /// Round to 1 decimal place (the ledger is reported in whole/near-whole minutes; diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepNightLabel.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepNightLabel.swift new file mode 100644 index 0000000000..48b675d9b3 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepNightLabel.swift @@ -0,0 +1,52 @@ +import Foundation + +/// How the Sleep hero names the night it is showing. +/// +/// Extracted from `SleepView` so it can be tested. The Kotlin twin has lived in `SleepHeroLogic.kt` +/// as a pure function with its own suite from the start; the Swift side kept it private inside the +/// view, which is why a defect here went uncaught on this platform until a report arrived. This +/// package's tests run in ordinary CI, unlike the app-target bundle, so the logic is verified on +/// every change rather than only when the on-demand app build is dispatched. +/// +/// Only the arithmetic lives here. The WORDING deliberately stays in the view: it returns a +/// `LocalizedStringKey`, so the literals have to remain in source for extraction, and pulling them +/// into a `String` helper here would quietly drop them out of the catalogue. The Kotlin twin can share +/// both halves because Android localises through resource ids instead. +/// +/// Kotlin twin: `calendarNightsAgo`. +public enum SleepNightLabel { + + /// How many nights back the carousel entry at `offset` is FROM TODAY. + /// + /// Measured from today, not from the newest recorded night. Anchoring on the newest record made + /// offset 0 always land on zero, so the hero read "Last night" over a night that could be days + /// old, printed directly above the correct date: two adjacent labels contradicting each other. + /// + /// `wakeTimestamps` is newest-first, one per carousel entry, each the entry's wake instant. It is + /// timestamps rather than sessions so this stays free of view types, and OPTIONAL so an entry with + /// no session falls back to the offset instead of being read as 1970. + /// + /// `today` must be the LOGICAL day (the 04:00 roll), and the wake instants must NOT be rolled. + /// The carousel groups nights by their calendar wake-date, so rolling that side too would let two + /// distinct entries collapse onto one label: a night ending 07:00 and the next ending 02:00 are + /// separate entries but the same logical day. Rolling only today is the half that matters, because + /// at 02:00 the night that ended yesterday morning is still "Last night". + /// + /// A NEGATIVE distance is normal, not a clock-skew guard: between waking before 04:00 and the + /// roll, the night's calendar date is already tomorrow relative to the logical day. Falling back + /// to the offset is the right answer there, so that branch carries a real case and must not be + /// narrowed to an error path. + public static func nightsAgo( + wakeTimestamps: [Int?], + offset: Int, + today: Date, + calendar: Calendar = .current + ) -> Int { + guard offset >= 0, offset < wakeTimestamps.count, + let shownTs = wakeTimestamps[offset] else { return offset } + let shown = calendar.startOfDay(for: Date(timeIntervalSince1970: TimeInterval(shownTs))) + let todayStart = calendar.startOfDay(for: today) + let d = calendar.dateComponents([.day], from: shown, to: todayStart).day ?? offset + return d >= 0 ? d : offset + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager+Trace.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager+Trace.swift index 659b9dabac..36118ee2e2 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager+Trace.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager+Trace.swift @@ -15,6 +15,54 @@ extension SleepStager { /// The gate-trace line formatters. Compact, parseable, no em-dashes. public enum GateTrace { + /// Why the HR-only spine did or did not run (#1801). Twin of the Kotlin `hrOnlyGateLine`. + /// + /// The funnel line below only exists when the spine is CALLED, so its absence was ambiguous in + /// exactly the way that made the first field report unreadable: it could mean the gate never + /// opened, or that the sink was never wired. This says which, on any day with no motion — the + /// only days where the question arises. + public static func hrOnlyGateLine(attempted: Bool, reason: String, + gravRows: Int, storedNights: Int) -> String { + "[sleep] hr-only gate attempted=\(attempted) reason=\(reason) " + + "grav=\(gravRows) stored=\(storedNights)" + } + + /// The HR-only spine's own funnel line (#1801). Byte-identical twin of the Kotlin + /// `SleepStagerTrace.hrOnlyLine`, so an Android and an Apple log of the same night compare + /// directly — which is the entire reason these formatters are twinned at all. + /// + /// The path shipped SILENT on both platforms, and a field log then showed `reason=no-motion` + /// with no way to tell whether the spine had run and found nothing or never ran. Every number + /// separates the two failures that actually happen: a band too tight (`sleepRuns` near zero) + /// from a duration gate eating real runs (`sleepRuns` high, `longestMin` under `minSleepMin`). + /// + /// `anchorBpm` and `bandBpm` are printed because they are DERIVED, not configured: the anchor is + /// a percentile of THIS window, so the same code gives every wearer a different threshold. + public static func hrOnlyLine(anchorBpm: Double?, bandBpm: Double?, + hrP50: Double?, hrP90: Double?, epochs: Int, + runs: Int, mergedRuns: Int, sleepRuns: Int, + longestSleepMin: Int, staged: Int, kept: Int, + minSleepMin: Int) -> String { + "[sleep] hr-only spine anchorBpm=\(round1(anchorBpm)) bandBpm=\(round1(bandBpm)) " + + "hrP50=\(round1(hrP50)) hrP90=\(round1(hrP90)) " + + "epochs=\(epochs) runs=\(runs) merged=\(mergedRuns) sleepRuns=\(sleepRuns) " + + "longestMin=\(longestSleepMin) staged=\(staged) kept=\(kept) minSleepMin=\(minSleepMin)" + } + + /// One decimal, by ARITHMETIC rather than `String(format:)`. Twin of the Kotlin `round1`, and + /// see its note: `printf` rounds half-to-even on the binary value while Java's `String.format` + /// rounds HALF_UP on the decimal expansion, so the two disagreed on 64.05. This does the same + /// IEEE arithmetic on both platforms and cannot. + /// + /// NON-NEGATIVE ONLY, same as the Kotlin twin: `-0.5` would print "0.5", because the integer + /// division truncates toward zero and the sign is then lost to `abs`. Every current caller is a + /// bpm, which cannot be negative. + private static func round1(_ v: Double?) -> String { + guard let v else { return "nil" } + let tenths = Int((v * 10.0).rounded()) + return "\(tenths / 10).\(abs(tenths % 10))" + } + /// One verdict line for a candidate run. `gate` names the constant that decided it /// (minSleepMin, maxMainSleepSpanS, offWrist, daytimeGuard, morningStillness, hrConfirm, /// sparseBridge, accepted); `detail` carries that gate's numbers. `startTs`/`endTs` give the diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift index 7be4760a44..4962a38fe9 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift @@ -50,16 +50,38 @@ public struct SleepSession: Equatable, Sendable { public let restingHR: Int? /// Mean RMSSD over 5-min windows across the session (ms), or nil. public let avgHRV: Double? + /// Staged WITHOUT a motion spine, from heart rate alone (#1801). + /// + /// True only for a strap that streams HR but banks no motion, where Stage 0's gravity-stillness + /// spine has nothing to work with. Such a night is weaker by construction, not by tuning: with + /// motion gone a quiet evening at rest can sit in the sleep band. It is allowed to describe itself + /// — duration, stages, Rest — and must NOT reach anything it cannot be unwound from, which is why + /// `restingHR` and `avgHRV` are left nil on one rather than filtered out downstream. + /// + /// Kotlin twin: `DetectedSleep.hrOnly` (the model names diverge, `DetectedSleep`/`SleepSession`). + public let hrOnly: Bool public init(start: Int, end: Int, efficiency: Double, stages: [StageSegment], - restingHR: Int?, avgHRV: Double?) { + restingHR: Int?, avgHRV: Double?, hrOnly: Bool = false) { self.start = start; self.end = end; self.efficiency = efficiency self.stages = stages; self.restingHR = restingHR; self.avgHRV = avgHRV + self.hrOnly = hrOnly } } public enum SleepStager { + // MARK: - #1943: RHR bin-gate thresholds (shared by sessionRestingHR and rhrBinGateLogLine) + // One constant per threshold, read by both the gate and its conformance check, so a drift + // between the two is structurally impossible rather than merely documented. + + /// Minimum samples in a 5-min bin for it to qualify as a candidate for the night's resting HR. + /// A one-sample bin at the edge of a wear gap cannot become the floor. + public static let rhrMinBinSamples: Int = 5 + /// Minimum plausible mean HR (bpm) for a bin to qualify. A dropout-driven sub-physiological + /// dip cannot become the floor. + public static let rhrMinPlausibleBpm: Double = 25 + // MARK: - Stage 0 constants (sleep.py) /// Per-sample gravity change (g) at/below which a sample is "still". @@ -224,6 +246,44 @@ public enum SleepStager { /// floor (a real continuous night never has a true >90 min wake bridge mid-sleep). public static let sparseBridgeGapMin: Int = 90 + /// A single intervening ACTIVE run up to this long may be absorbed when bridging two sleep runs + /// (#1657). + /// + /// This started as `maxGapMin` (20), on the reasoning that the file already had a threshold for + /// "a discontinuity this long is decisive". An end-to-end test through `detectSleep` showed that was + /// too tight to reach the case the issue is about: a FIFTEEN-minute interruption produced a + /// TWENTY-ONE-minute active run, because `classifyStill` smears the still/moving boundary by roughly + /// its rolling window and `buildRuns` closes runs at sample edges. The detected run is systematically + /// longer than the interruption it represents, so a bound set from the interruption's true length + /// rejects it. + /// + /// 30 is a judgement, and stated as one rather than dressed up as derived: a realistic trip out of + /// bed of up to about a quarter of an hour, plus the ~6 minutes of smear that measurement showed, + /// with a little headroom. It is deliberately well under `minSleepMin` — an interruption long enough + /// to be a session in its own right is a genuine awakening and should split the night. + /// + /// The bound is the cheap half of the guard. The real one is the HR band across the whole span: a + /// wearer who is actually up keeps HR elevated for the duration and fails it, while a brief stir does + /// not. `mergeMin` (15) already absorbs shorter active runs upstream in `mergePeriods`. + public static let sparseBridgeActiveMaxMin: Int = 30 + + /// The same bound when HR across the whole span stays in the sleep band. + /// + /// `sparseBridgeActiveMaxMin`'s own doc calls the minute bound "the cheap half of the guard" and the + /// HR band "the real one" — but the check order meant the cheap half vetoed first, so the real one + /// never got to speak for a run over 30 minutes. A 42-minute active run with sleep-band HR was + /// rejected identically to one with a wearer plainly up and about. + /// + /// That is the wrong way round on a strap whose "active" verdict comes from MOTION, which is the + /// sparse and unreliable signal on the hardware this fires for (field log 260901-1022: 20,647 gravity + /// samples across a 54-hour window, against 109,868 HR). HR is the better witness there, and it is + /// already computed. 60 rather than something larger because it is `minSleepMin`: an interruption + /// long enough to be a session in its own right is a genuine awakening whatever HR says. + /// + /// Applied as a MAXIMUM against `sparseBridgeActiveMaxMin`, never a replacement, so the in-band path + /// can only ever be more permissive — an out-of-band span keeps the 30-minute bound exactly. + public static let sparseBridgeActiveMaxInBandMin: Int = 60 + // MARK: - Stage 1–3 constants (sleep_features.py) public static let epochS: Double = 30.0 @@ -358,8 +418,15 @@ public enum SleepStager { guard let baseline = baseline else { return false } let seg = hr.filter { $0.ts > a && $0.ts <= b } if seg.isEmpty { return false } - let meanHR = Double(seg.reduce(0) { $0 + $1.bpm }) / Double(seg.count) - return meanHR <= baseline * hrSleepBandMult + // MEDIAN, not mean (#1657). `confirmSleepWithHR` below already documents why the mean is the + // wrong statistic here — "a real sleep night carries brief arousal / wake HR spikes (observed to + // ~190 bpm)" that drag it above the band — and uses the median for exactly that reason. This gate + // answers the same question over a SHORTER window, where a single spike dominates the mean even + // harder: a two-minute stir inside a fifteen-minute interval can put the mean out of band while + // the wearer was asleep for thirteen of those minutes. The median rejects a SUSTAINED elevation + // just as firmly, which is the discrimination this gate exists to make. + let medianHR = HRVAnalyzer.median(seg.map { Double($0.bpm) }) + return medianHR <= baseline * hrSleepBandMult } /// Per-record sleep flags from a rolling fraction of "still" samples. @@ -427,6 +494,234 @@ public enum SleepStager { return periods } + /// Percentile of the window's bpm that anchors the HR-only sleep band. + /// + /// NOT `hrBaseline`. That is the window MEDIAN, and it is the right anchor where it is used — as a + /// CONFIRMATION gate on a run gravity stillness already found, where being permissive is deliberate. + /// As a PRIMARY threshold it is disqualified by arithmetic rather than by tuning: a median splits the + /// samples in half by definition, so a band of `median * 1.05` admits strictly more than half of any + /// window whatever the data. Measured on a realistic 24 h (16 h awake 74-96, 8 h night 59-70) it + /// called 14.4 hours sleep against a truth of 8. + /// + /// A tenth percentile sits in the night's trough instead, which is what a sleep band should be + /// anchored to, and cannot admit half the window however the day is shaped. + public static let hrOnlyAnchorPercentile: Double = 0.10 + + /// Multiplier above `hrOnlyAnchorPercentile` that still counts as asleep. + /// + /// Numerically equal to `hrSleepBandMult` today, and deliberately a SEPARATE constant: that one is a + /// confirmation gate's tolerance and this one is a detector's, and a future change to either has no + /// business silently moving the other. + /// + /// Chosen conservatively from a sweep over anchor x multiplier against windows with known truth, + /// because the two failure directions are not symmetric. Over-detection puts a wrong Rest number on + /// screen; under-detection leaves "No data", which is the state this feature is trying to improve on + /// and therefore a safe place to fail. p10 x 1.05 measured 8.1 h against a truth of 8 on a + /// field-shaped 24 h window, and under-reads a long multi-night window rather than over-reading it + /// (8.7 h of 16 h across two nights in 54 h) — pinned in `SleepStagerHrOnlyAnchorTests`. + public static let hrOnlyBandMult: Double = 1.05 + + /// The `hrOnlyAnchorPercentile` of `hr` by bpm, or nil when empty. Nearest-rank (no interpolation), so + /// the value is always one the wearer actually recorded and the two platforms cannot disagree on a + /// rounding rule. + static func hrOnlyBaseline(_ hr: [HRSample]) -> Double? { + hrPercentile(hr, hrOnlyAnchorPercentile) + } + + /// The `p` percentile of `hr` by bpm, nearest-rank. Shared with `hrOnlyBaseline` so the spread the + /// trace reports is measured by the SAME rule as the anchor it is meant to be judged against. + static func hrPercentile(_ hr: [HRSample], _ p: Double) -> Double? { + percentileOfSorted(hr.map { Double($0.bpm) }.sorted(), p) + } + + /// The `p` percentile of an ALREADY-SORTED bpm list, nearest-rank. Split out because the caller + /// needs three percentiles from the same window, and the obvious spelling sorts once per + /// percentile — ~160k samples sorted three times per scored day across a 21-day rescore. + static func percentileOfSorted(_ sorted: [Double], _ p: Double) -> Double? { + if sorted.isEmpty { return nil } + let idx = min(max(Int(Double(sorted.count - 1) * p), 0), sorted.count - 1) + return sorted[idx] + } + + /// How many distinct `hrOnlyEpochS` buckets `sortedByTs` spans. + /// + /// A single pass rather than a Set, because `hrS` is already sorted by timestamp so the bucket key + /// is non-decreasing. The obvious spelling (`Set(hrS.map { … })`) builds a full intermediate array + /// AND a set over every sample to end up with a few thousand distinct keys — per scored day, across + /// the 21-day rescore. A diagnostic must not cost what it is measuring. + static func distinctEpochs(_ sortedByTs: [HRSample]) -> Int { + var count = 0 + var last = Int.min + for s in sortedByTs { + let key = s.ts / hrOnlyEpochS + if key != last { count += 1; last = key } + } + return count + } + + /// Epoch for the HR-only spine, in seconds. + public static let hrOnlyEpochS: Int = 60 + + /// Sleep/active runs built from HEART RATE ALONE, for a strap that streams HR but banks no motion. + /// + /// Stage 0 is normally a gravity-stillness spine (Cole-Kripke) that HR only CONFIRMS, via + /// `hrSleepBandAcross` and `confirmSleepWithHR`. A WHOOP 5/MG that cannot bond never banks motion at + /// all — `SET_CLOCK` rides a handshake it never completes — so `grav` is empty, there is no spine, and + /// no quantity of HR can stage the night (#1801). This builds the spine from the one signal such a + /// strap does provide. + /// + /// Deliberately the SAME rule the confirm path already trusts: per-epoch MEDIAN bpm against + /// `baseline * hrSleepBandMult`, median for the reason `hrSleepBandAcross` spells out. The run + /// construction follows `buildRuns` — close on a class change or a gap over `maxGapMin` — so the two + /// spines segment alike once flags exist, and only the flag SOURCE differs. + /// + /// With ONE branch deliberately absent, and it is not an oversight. `buildRuns` can forgive a gap when + /// `hrSleepBandAcross` vouches that HR stayed in band across it, which rescues a night whose GRAVITY + /// dropped out. Here the gap IS in the heart rate, so there is nothing left to vouch with and no + /// analogue to port. A long HR dropout therefore breaks an HR-only run where it would not break a + /// motion-backed one, and a night fragmented that way is dropped by the caller's minimum-duration + /// gate rather than bridged. + /// + /// WEAKER THAN THE MOTION SPINE, by construction rather than by tuning. The file already notes that a + /// long still daytime stretch is gravity-indistinguishable from a nap and that HR is what saves it; + /// with motion gone the inverse is exposed, and a quiet evening at rest can sit in the sleep band. A + /// caller must treat these runs as lower-confidence than a motion-backed night and must not let one + /// reach a baseline it cannot be unwound from. + /// + /// Bucket order does not depend on sort stability: samples are grouped by epoch and reduced with a + /// median, so their order within an epoch cannot change the result. + static func hrOnlySleepRuns(_ hr: [HRSample], baseline: Double?, + epochS: Int = hrOnlyEpochS, + maxGapMinutes: Int = maxGapMin) -> [Period] { + guard let baseline = baseline, baseline > 0 else { return [] } + if hr.isEmpty || epochS <= 0 { return [] } + var byEpoch: [Int: [Double]] = [:] + var lastTs: [Int: Int] = [:] + for s in hr { + let k = s.ts / epochS + byEpoch[k, default: []].append(Double(s.bpm)) + lastTs[k] = max(lastTs[k] ?? Int.min, s.ts) + } + let keys = byEpoch.keys.sorted() + // Two axes, deliberately. A gap and a run's START use the epoch's own start, so a gap is measured + // between epochs rather than between whichever samples sat at their edges. A run's END is the last + // SAMPLE observed in its final epoch, which is what `buildRuns` means by `end` — reading the epoch + // start there would report every run one whole epoch shorter than the data it covers, and that + // understatement would then be weighed against the caller's minimum-duration gate. + let times = keys.map { $0 * epochS } + let ends = keys.map { lastTs[$0]! } + let flags = keys.map { HRVAnalyzer.median(byEpoch[$0]!) <= baseline * hrOnlyBandMult } + let maxGapS = maxGapMinutes * 60 + var periods: [Period] = [] + var runStart = 0 + for i in 1...keys.count { + let atEnd = (i == keys.count) + let close: Bool + if atEnd { + close = true + } else { + close = flags[i] != flags[runStart] || (times[i] - times[i - 1]) > maxGapS + } + if close { + periods.append(Period(stage: flags[runStart] ? "sleep" : "active", + start: times[runStart], end: ends[i - 1])) + runStart = i + } + } + return periods + } + + /// Whole sleep SESSIONS from heart rate alone, for a strap that banks no motion (#1801). + /// + /// `hrOnlySleepRuns` supplies the spine this normally gets from gravity stillness; `SleepStagerV2` + /// then stages each surviving run from HR and R-R with an EMPTY gravity array. That is not a + /// degenerate call: V2's epoch features read HR and R-R directly and only its motion-quiescence terms + /// go quiet, so it returns a real hypnogram rather than one flat stage. A stageless session would be + /// dropped by `sleepSessionFromProvided` anyway, so a night that fails to stage is correctly omitted + /// here rather than passed on hollow. + /// + /// The anchor is `hrOnlyBaseline` — the `hrOnlyAnchorPercentile` of the window — and NOT the + /// `hrBaseline` median the motion path derives. Reusing that median looked like parity and was a bug: + /// as a confirmation gate on an already-detected run it is deliberately permissive, but as a primary + /// threshold it admits over half of any window by definition. See `hrOnlyAnchorPercentile`. + /// + /// `restingHR` and `avgHRV` are MEASURED here and reported (#1884). They were withheld under #1801, + /// which treated them as inferred; they are not. Only the session BOUNDS are inferred from heart rate + /// — that is what `hrOnly` marks — while each RMSSD is computed over its own 5-minute window, so fuzzy + /// bounds change WHICH windows are included, not whether any one of them is valid. Withholding them + /// discarded a measured 22-25 ms and left Charge with no input at all, which is a worse error than a + /// slightly fuzzy one. + /// + /// The flag travels on instead, so a consumer that wants to weigh an HR-only night down still can. + /// `public` because the app target calls it: `Strand/Data/IntelligenceEngine.swift` is the day scan, + /// and it lives outside this package. The spine and the anchor below it stay `internal` — the tests + /// reach them with `@testable`, and nothing outside should be building its own spine. + public static func hrOnlySessions(hr: [HRSample], rr: [RRInterval], resp: [RespSample], + minMinutes: Int = minSleepMin, + traceSink: ((String) -> Void)? = nil) -> [SleepSession] { + let hrS = hr.sorted { $0.ts < $1.ts } + // ONE sort of the bpm axis, reused for the anchor and for the spread the trace reports. + let sortedBpm = hrS.map { Double($0.bpm) }.sorted() + guard let baseline = percentileOfSorted(sortedBpm, hrOnlyAnchorPercentile) else { + traceSink?(GateTrace.hrOnlyLine(anchorBpm: nil, bandBpm: nil, hrP50: nil, hrP90: nil, + epochs: 0, runs: 0, + mergedRuns: 0, sleepRuns: 0, longestSleepMin: 0, + staged: 0, kept: 0, minSleepMin: minMinutes)) + return [] + } + let rrS = rr.sorted { $0.ts < $1.ts } + var out: [SleepSession] = [] + // mergePeriods for the same reason the motion path calls it: a run boundary is a threshold + // crossing, and a sleeping heart rate oscillates across the band all night. Without this the + // spine returns the night's minutes correctly but shredded into sub-mergeMin fragments, every one + // of which then fails the minimum-duration gate below — 8 h of detected sleep yielding zero + // sessions. Absorbing the short runs first is what turns a spine into a night. + let rawRuns = hrOnlySleepRuns(hrS, baseline: baseline) + let merged = mergePeriods(rawRuns) + var staged = 0 + var longestSleepS = 0 + for p in merged { + if p.stage != "sleep" { continue } + longestSleepS = max(longestSleepS, p.end - p.start) + if (p.end - p.start) < minMinutes * 60 { continue } + let stages = SleepStagerV2.stageSession(start: p.start, end: p.end, grav: [], + hr: hrS, rr: rrS, resp: resp) + staged += 1 + if stages.isEmpty { continue } + // #1884: MEASURED, not nilled. The bounds here are inferred from heart rate, which is why + // `hrOnly` marks the session — but each RMSSD is computed over its own 5-minute window tagged + // by the stage at its centre, so fuzzy bounds change WHICH windows are included, not whether + // any one of them is valid. A field log showed this path computing a stable 22-25ms deepOnly + // and discarding it, leaving Charge unscoreable. `hrOnly` still travels with the session, so + // it is a quality marker now rather than a delete. Kotlin twin: `SleepStager.hrOnlySessions`. + out.append(SleepSession(start: p.start, end: p.end, + efficiency: efficiency(start: p.start, end: p.end, stages: stages), + stages: stages, + restingHR: sessionRestingHR(start: p.start, end: p.end, hr: hrS), + avgHRV: sessionAvgHRV(start: p.start, end: p.end, rr: rrS), + hrOnly: true)) + } + traceSink?(GateTrace.hrOnlyLine( + anchorBpm: baseline, + bandBpm: baseline * hrOnlyBandMult, + // The wearer's own spread. An anchor alone cannot be judged: p10 of 60 means one thing when + // the median is 63 and quite another when it is 74, and only the second leaves a night the + // band can separate. + hrP50: percentileOfSorted(sortedBpm, 0.50), + hrP90: percentileOfSorted(sortedBpm, 0.90), + // The real epoch count, not the sample count: the spine buckets by `hrOnlyEpochS` before it + // decides anything, so this is the axis every other number here is measured on. + epochs: distinctEpochs(hrS), + runs: rawRuns.count, + mergedRuns: merged.count, + sleepRuns: merged.filter { $0.stage == "sleep" }.count, + longestSleepMin: longestSleepS / 60, + staged: staged, + kept: out.count, + minSleepMin: minMinutes)) + return out + } + /// Absorb runs shorter than mergeMin minutes into their neighbours. static func mergePeriods(_ periods: [Period], mergeMinutes: Int = mergeMin) -> [Period] { if periods.isEmpty { return [] } @@ -472,11 +767,15 @@ public enum SleepStager { struct SparseBridgeAttempt: Equatable, Sendable { /// Gap between the two runs, in whole minutes (negative when they overlap). let gapMin: Int + /// Minutes of intervening ACTIVE run, or 0 when the pair was only separated by a gap (#1657). + let activeMin: Int + /// The bound this pair was actually judged against — 30, or 60 when HR stayed in the sleep band. + let activeCapMin: Int /// Whether the intervening HR stayed in the sleep band (the bridge's second condition). let hrInSleepBand: Bool /// Whether this pair was merged. let bridged: Bool - /// Stable token for the log: bridged / gapTooLong / hrOutOfBand / overlap. + /// Stable token for the log: bridged / gapTooLong / hrOutOfBand / overlap / activeTooLong. let reason: String } @@ -485,57 +784,101 @@ public enum SleepStager { /// actually happened rather than an approximation. Only pairs the bridge itself CONSIDERS (two /// adjacent sleep runs) produce an attempt; a pair separated by an active run is never considered, /// which is itself the answer when no attempts are reported. Pure — no I/O, no side effects. - static func sparseBridgeAttempts(_ periods: [Period], sparse: Bool, - hr: [HRSample], baseline: Double?) -> [SparseBridgeAttempt] { - guard sparse, !periods.isEmpty else { return [] } + /// The bridge, plus what it considered. See `bridgeSparseSleep` for the merge rule. + /// + /// #1657: an intervening ACTIVE run no longer blocks the merge permanently. The original loop could + /// only join runs already adjacent in its own output, so any active run between two sleep runs was + /// appended first and made the next pair unreachable — and a field trace found the bridge merging + /// NOTHING on 14 of 14 sparse nights for exactly that reason. Since a bathroom trip is definitionally + /// an active run, the rescue built for fragmentation was unavailable in the case that needs it most. + /// + /// A single active run up to `sparseBridgeActiveMaxMin` is now absorbed, with the whole span still + /// subject to `sparseBridgeGapMin` and to the HR band. Two or more consecutive active runs are not: + /// that is a night with real structure in it, not one interruption. + /// + /// This USED to be a shadow copy of the loop kept only for tracing, which had to be edited in step + /// with the real one — a trace that quietly disagrees with the behaviour it describes is worse than + /// no trace. Merge and trace are one pass now. Kotlin twin: `bridgeSparseSleepTraced`. + static func bridgeSparseSleepTraced(_ periods: [Period], sparse: Bool, hr: [HRSample], + baseline: Double?) -> ([Period], [SparseBridgeAttempt]) { + if !sparse || periods.isEmpty { return (periods, []) } let bridgeGapS = sparseBridgeGapMin * 60 - var attempts: [SparseBridgeAttempt] = [] + let activeMaxS = sparseBridgeActiveMaxMin * 60 + let activeMaxInBandS = sparseBridgeActiveMaxInBandMin * 60 var out: [Period] = [] + var attempts: [SparseBridgeAttempt] = [] + + /// Judge one candidate pair, record it, and merge when it passes. + /// + /// The order of the checks fixes which reason a failing pair reports, and it is deliberate: + /// overlap and gap are properties of the pair, activeTooLong is a property of what sits between + /// them, and hrOutOfBand is last because it is the only one that needed the HR series to decide. + /// `activeMaxInBandS` is a PARAMETER rather than a capture, mirroring the Kotlin default of 0. + /// Captured, case 1 (adjacent sleep runs, no intervening active run) would report activeCapMin=60 + /// on Apple and 0 on Android for the identical decision — same behaviour, divergent trace, which + /// is exactly the byte-for-byte comparison these lines exist to allow. + func consider(_ left: Period, _ right: Period, activeS: Int, + dropTrailing: Bool, activeMaxS: Int, activeMaxInBandS: Int = 0) -> Bool { + let gap = right.start - left.end + let inBand = hrSleepBandAcross(left.end, right.start, hr: hr, baseline: baseline) + // The HR band is the real guard, so let it widen the minute bound rather than be vetoed by + // it. max, not a swap: in-band can only ever be MORE permissive, and case 1 (activeMaxS = 0, + // no intervening run) is untouched because activeS is 0 there too. + let activeCapS = inBand ? max(activeMaxS, activeMaxInBandS) : activeMaxS + let reason: String + if gap < 0 { reason = "overlap" } + else if gap > bridgeGapS { reason = "gapTooLong" } + else if activeS > activeCapS { reason = "activeTooLong" } + else if !inBand { reason = "hrOutOfBand" } + else { reason = "bridged" } + let bridged = reason == "bridged" + attempts.append(SparseBridgeAttempt(gapMin: gap / 60, activeMin: activeS / 60, + activeCapMin: activeCapS / 60, + hrInSleepBand: inBand, bridged: bridged, reason: reason)) + guard bridged else { return false } + if dropTrailing { out.removeLast() } + out[out.count - 1] = Period(stage: "sleep", start: left.start, end: right.end) + return true + } + for p in periods { - if let last = out.last, last.stage == "sleep", p.stage == "sleep" { - let gap = p.start - last.end - let inBand = hrSleepBandAcross(last.end, p.start, hr: hr, baseline: baseline) - let bridged = gap >= 0 && gap <= bridgeGapS && inBand - let reason: String - if bridged { reason = "bridged" } - else if gap < 0 { reason = "overlap" } - else if gap > bridgeGapS { reason = "gapTooLong" } - else { reason = "hrOutOfBand" } - attempts.append(SparseBridgeAttempt(gapMin: gap / 60, hrInSleepBand: inBand, - bridged: bridged, reason: reason)) - if bridged { - out[out.count - 1] = Period(stage: "sleep", start: last.start, end: p.end) - continue + if p.stage == "sleep" { + // Case 1: the previous run is sleep — the original adjacent-pair merge. + if let last = out.last, last.stage == "sleep" { + if consider(last, p, activeS: 0, dropTrailing: false, activeMaxS: 0) { continue } + } + // Case 2: exactly one active run sits between two sleep runs. Absorbed when short + // enough, which is the #1657 case the original loop could never reach. + if out.count >= 2, let last = out.last, last.stage == "active", + out[out.count - 2].stage == "sleep" { + let prev = out[out.count - 2] + if consider(prev, p, activeS: last.end - last.start, + dropTrailing: true, activeMaxS: activeMaxS, + activeMaxInBandS: activeMaxInBandS) { continue } } } out.append(p) } - return attempts + return (out, attempts) } - /// Sparse-gravity bridge (#308): merge two adjacent SLEEP runs separated ONLY by a gap up to - /// sparseBridgeGapMin minutes when the intervening HR stays in the sleep band — so a real night - /// fragmented by gravity dropouts is re-stitched into one continuous in-bed span BEFORE the - /// minSleepMin gate drops the pieces. Active runs and over-threshold gaps are left untouched; - /// the span between two bridged sleep runs (an "active"/gap run, if present) is absorbed. - /// A no-op when `sparse == false`, so the dense 4.0 path is unchanged. + /// Sparse-gravity bridge (#308): merge two SLEEP runs when the intervening HR stays in the sleep + /// band — so a real night fragmented by gravity dropouts is re-stitched into one continuous in-bed + /// span BEFORE the minSleepMin gate drops the pieces. A no-op when `sparse == false`, so the dense + /// 4.0 path is unchanged. + /// + /// What sits between the two runs may be a bare gap (up to `sparseBridgeGapMin`) or ONE active run + /// (additionally up to `sparseBridgeActiveMaxMin`). Over-threshold gaps, longer active runs and two + /// consecutive active runs are all left untouched. + /// + /// The previous wording here — "Active runs … are left untouched; the span between two bridged sleep + /// runs (an "active"/gap run, if present) is absorbed" — read as though an intervening active run was + /// already handled. It was not: only a bare gap was, and that sentence is a large part of why #1657 + /// went unnoticed. Kept in the history rather than quietly deleted, because the next person to widen + /// this function will read this comment first. static func bridgeSparseSleep(_ periods: [Period], sparse: Bool, hr: [HRSample], baseline: Double?) -> [Period] { - if !sparse || periods.isEmpty { return periods } - let bridgeGapS = sparseBridgeGapMin * 60 - var out: [Period] = [] - for p in periods { - if let last = out.last, last.stage == "sleep", p.stage == "sleep" { - let gap = p.start - last.end - if gap >= 0 && gap <= bridgeGapS - && hrSleepBandAcross(last.end, p.start, hr: hr, baseline: baseline) { - out[out.count - 1] = Period(stage: "sleep", start: last.start, end: p.end) - continue - } - } - out.append(p) - } - return out + bridgeSparseSleepTraced(periods, sparse: sparse, hr: hr, baseline: baseline).0 } // MARK: - HR refinement @@ -1048,9 +1391,9 @@ public enum SleepStager { let runsBeforeBridge = traceSink == nil ? 0 : runs.filter { $0.stage == "sleep" }.count // #737: capture the per-pair reasons BEFORE the merge mutates `runs`, so a bridge that changed // nothing still says why (gapTooLong / hrOutOfBand / overlap) instead of only before==after. - let bridgeAttempts = traceSink == nil ? [] : sparseBridgeAttempts(runs, sparse: sparse, hr: hrS, - baseline: baseline) - runs = bridgeSparseSleep(runs, sparse: sparse, hr: hrS, baseline: baseline) + let bridgeResult = bridgeSparseSleepTraced(runs, sparse: sparse, hr: hrS, baseline: baseline) + let bridgeAttempts = bridgeResult.1 + runs = bridgeResult.0 // Sleep & Rest test mode (E3): record the sparse-gravity bridge result, so a sparse 5.0 night // rescued from fragmentation is visible. Only emitted when gravity is sparse (the only case the // bridge can act) and only when tracing. Side-effect-only. @@ -1059,19 +1402,24 @@ public enum SleepStager { traceSink(GateTrace.runLine(index: -1, startTs: 0, endTs: 0, verdict: runsAfterBridge < runsBeforeBridge ? .kept : .dropped, gate: "sparseBridge", detail: "sparse=true gapMin=\(sparseBridgeGapMin) runsBefore=\(runsBeforeBridge) runsAfter=\(runsAfterBridge)")) - // #737: one line per pair the bridge CONSIDERED. No lines at all means no two adjacent sleep - // runs were ever seen — i.e. the fragments are separated by active runs, which the bridge - // deliberately never crosses. That absence is itself the diagnosis. + // #737: one line per pair the bridge CONSIDERED, each naming what it decided. + // + // #1657 changed what an empty list MEANS, so the wording changed with it. It used to mean + // "the fragments are separated by active runs", because such a pair could never be reached — + // that absence was the diagnosis. A single short active run is now a considered pair, so an + // empty list can only mean there was no candidate at all: one sleep run, or fragments split + // by two or more consecutive active runs. Leaving the old text would have pointed the next + // reader at a cause that had just been removed. if bridgeAttempts.isEmpty { traceSink(GateTrace.runLine(index: -1, startTs: 0, endTs: 0, verdict: .dropped, gate: "sparseBridge", - detail: "no adjacent sleep pairs considered (fragments separated by active runs)")) + detail: "no candidate pairs (one sleep run, or fragments split by consecutive active runs)")) } for (i, a) in bridgeAttempts.enumerated() { traceSink(GateTrace.runLine(index: -1, startTs: 0, endTs: 0, verdict: a.bridged ? .kept : .dropped, gate: "sparseBridgePair", - detail: "pair=\(i) gapMin=\(a.gapMin) hrInSleepBand=\(a.hrInSleepBand) " - + "reason=\(a.reason)")) + detail: "pair=\(i) gapMin=\(a.gapMin) activeMin=\(a.activeMin) activeCapMin=\(a.activeCapMin) " + + "hrInSleepBand=\(a.hrInSleepBand) reason=\(a.reason)")) } } @@ -2436,18 +2784,101 @@ public enum SleepStager { // MARK: - Per-session HR / HRV /// Lowest 5-min rolling-mean HR during the session (bpm), or nil. + /// + /// The window is CLOSED at both ends, so the binning is too: bins are `[t, t + windowS)` except + /// the final one, which is `[t, end]`. Half-open bins alone would admit a sample sitting exactly + /// on an aligned `end` through the prefilter and then place it in no bin — counted as data, + /// silently ignored. A zero-length window (`start == end`) is that single closed bin. + /// #1943: a conformance check that reports when the artefact gate `sessionRestingHR` applies + /// actually MOVED the floor. The shipped floor IS the gated floor, so comparing the gated floor + /// against it is silent by construction. Instead, this reports the UNGATED floor (the old rule: + /// min over every non-empty bin) against the shipped one, so the line fires when the gate + /// excluded the bin that would otherwise have won — which is exactly the frequency and magnitude + /// the measure-only diagnostic was meant to learn, and would never have reported otherwise. + /// + /// `sessionRestingHR` gates bins on `rhrMinBinSamples` and `rhrMinPlausibleBpm` before letting them + /// win the floor, falling back to the lowest of all bin means when no bin qualifies. This helper + /// reproduces the same partition and the same gate, and ALSO computes the ungated floor (min over + /// every non-empty bin, the pre-#1943 rule) so the two can be compared. + /// + /// Bins are built exactly as `sessionRestingHR` builds them, closed final bin included, or the line + /// would describe a different partition than the one it is judging. + /// + /// Returns nil unless the gate actually MOVED the floor (ungated != shipped), so the log carries + /// only the nights the gate did something. If it turns out to fire on one night in fifty, that is + /// worth knowing; if it fires nightly, that is worth knowing sooner. + /// + /// The counts still ride along when the line does fire, since they are the context for the change. + /// Same posture as the over-count-only R-R dump. Counts and bpm only, no timestamps. Pure. Twin of + /// Kotlin `rhrBinGateLogLine`. + public static func rhrBinGateLogLine(day: String, sessions: [(Int, Int)], hr: [HRSample], + shippedFloor: Int, minBinSamples: Int = rhrMinBinSamples, + minPlausibleBpm: Double = rhrMinPlausibleBpm) -> String? { + let windowS = 5 * 60 + var bins = 0, thin = 0, implausible = 0, ungatedN = 0 + var ungated: Double? + var gated: Double? + for (start, end) in sessions { + let seg = hr.filter { $0.ts >= start && $0.ts <= end } + if seg.isEmpty { continue } + var t = start + repeat { + let isFinal = t + windowS >= end + let win = seg.filter { $0.ts >= t && (isFinal || $0.ts < t + windowS) } + if !win.isEmpty { + bins += 1 + let mean = Double(win.reduce(0) { $0 + $1.bpm }) / Double(win.count) + if win.count < minBinSamples { thin += 1 } + if mean < minPlausibleBpm { implausible += 1 } + if ungated == nil || mean < ungated! { ungated = mean; ungatedN = win.count } + if win.count >= minBinSamples, mean >= minPlausibleBpm, + gated == nil || mean < gated! { gated = mean } + } + t += windowS + } while t < end + } + if bins == 0 { return nil } + let ungatedFloor = ungated.map { Int($0.rounded()) } + // The gate moved the floor when the ungated floor differs from the shipped one. The shipped + // floor IS the gated floor, so this fires when the gate excluded the bin that would have won + // under the old rule — which is the frequency and magnitude we want to learn. + let moved = ungatedFloor != nil && ungatedFloor != shippedFloor + if !moved { return nil } + let gatedFloor = gated.map { Int($0.rounded()) } + return "rhr bins day=\(day) bins=\(bins) thin=\(thin) implausible=\(implausible) " + + "winnerN=\(ungatedN) ungated=\(ungatedFloor.map(String.init) ?? "nil") " + + "gated=\(gatedFloor.map(String.init) ?? "nil") shipped=\(shippedFloor) gateMoved=\(moved)" + } + static func sessionRestingHR(start: Int, end: Int, hr: [HRSample]) -> Int? { let seg = hr.filter { $0.ts >= start && $0.ts <= end } guard !seg.isEmpty else { return nil } let windowS = 5 * 60 - var means: [Double] = [] + // #1943: a bin qualifies to WIN the floor only when it is well-populated (≥ rhrMinBinSamples) + // and its mean is physiologically plausible (≥ rhrMinPlausibleBpm). A one-sample bin at the + // edge of a wear gap, or a dropout-driven sub-physiological dip, cannot become the night's + // resting HR — that number is displayed, stored on the daily row, and fed to the baseline + // later nights are scored against. If no bin qualifies, fall back to the lowest of ALL bin + // means (ungated), then the all-sample mean — preserving the never-null-on-data behaviour. + var gatedMeans: [Double] = [] + var allMeans: [Double] = [] var t = start - while t < end { - let win = seg.filter { $0.ts >= t && $0.ts < t + windowS } - if !win.isEmpty { means.append(Double(win.reduce(0) { $0 + $1.bpm }) / Double(win.count)) } + repeat { + // The last bin (its half-open end reaches or passes `end`) closes on `end` instead, + // catching an endpoint sample the prefilter already admitted. `seg` holds nothing past + // `end`, so "everything from t onwards" IS [t, end]. `repeat` runs once for a + // zero-length window, where that single closed bin is the whole window. + let isFinal = t + windowS >= end + let win = seg.filter { $0.ts >= t && (isFinal || $0.ts < t + windowS) } + if !win.isEmpty { + let mean = Double(win.reduce(0) { $0 + $1.bpm }) / Double(win.count) + allMeans.append(mean) + if win.count >= rhrMinBinSamples && mean >= rhrMinPlausibleBpm { gatedMeans.append(mean) } + } t += windowS - } - if let m = means.min() { return Int(m.rounded()) } + } while t < end + if let m = gatedMeans.min() { return Int(m.rounded()) } + if let m = allMeans.min() { return Int(m.rounded()) } let all = Double(seg.reduce(0) { $0 + $1.bpm }) / Double(seg.count) return Int(all.rounded()) } @@ -2465,12 +2896,42 @@ public enum SleepStager { /// Uses the same range-filter + ≥2-valid-interval rule as hrv.rmssd(). static func sessionAvgHRV(start: Int, end: Int, rr: [RRInterval]) -> Double? { let vals = sessionHrvWindows(start: start, end: end, rr: rr, stages: []).compactMap { $0.rmssd } - return vals.isEmpty ? nil : vals.reduce(0, +) / Double(vals.count) + if vals.isEmpty { return nil } + // #1118: refuse the night outright when its own R-R banks more beat-time than the wall clock it + // spans. Gated HERE rather than at the caller because this is where RMSSD BECOMES the day's HRV: + // one seam covers the daily row, the sleep-session cache, the Health card and the baseline that + // later nights are scored against, so none of them can end up disagreeing about whether the night + // was trustworthy. See `HRVAnalyzer.successiveDiffIsTrustworthy` for why an over-count corrupts a + // successive-difference statistic and why a blank is the right answer. + // + // Classified over the SAME beats the value was built from, windowed [start, end] exactly as + // `sessionHrvWindows` does, so the verdict cannot describe a different set of beats than the number + // it is gating. + let seg = rr.filter { $0.ts >= start && $0.ts <= end } + let segTs = seg.map { $0.ts } + let segMs = seg.map { Double($0.rrMs) } + let coverage = HRVAnalyzer.rrCoverage(tsSec: segTs, rrMs: segMs) + // `collapsed` is deliberately the SAME figure as `coverage`, which pins every over-count here to + // crossSecondOverCount. That is not a claim about which kind it is. The collapsed figure exists only + // to choose BETWEEN the two over-count verdicts, and this gate refuses both, so the real one would + // change no outcome — while costing a full sort of the night's ~50-70k beats, since + // `collapsedCoverage` opens with a sort. This runs per session, per day, across ~21 days of every + // analyzeRecent, every 15 minutes; #1510 cut this exact path from six sorts a night to two, and + // buying a distinction the caller discards would hand that back. `rrCoverage` is a single O(n) + // pass. If a future gate ever needs the two over-count cases apart, compute it then. + let verdict = HRVAnalyzer.classifyCoverage(coverage: coverage, collapsed: coverage) + guard HRVAnalyzer.successiveDiffIsTrustworthy(verdict) else { return nil } + return vals.reduce(0, +) / Double(vals.count) } /// Per-5-min-window RMSSD across a session, each window tagged with the sleep stage at its CENTER /// (from `stages`) — the SINGLE source `sessionAvgHRV` averages, and the HRV nightly trace reads. /// Passing `[]` for `stages` tags every window "?" (the plain-average path needs no stages). (#141) + /// + /// Windows follow the same closed-window rule as `sessionRestingHR`: `[t, t + windowS)` except + /// the final one, which is `[t, end]`, so a beat sitting exactly on an aligned `end` lands in a + /// window instead of being admitted by the prefilter and then dropped. Window stage tagging and + /// `startTs` are unchanged — the final window keeps its half-open center `t + windowS / 2`. static func sessionHrvWindows(start: Int, end: Int, rr: [RRInterval], stages: [StageSegment]) -> [HrvWindow] { // CONTRACT: `rr` MUST already be ts-sorted (RMSSD is built from SUCCESSIVE differences, so a bucket // has to be chronological). The value path passes the loop's pre-sorted `rrS`; the trace caller sorts @@ -2481,8 +2942,12 @@ public enum SleepStager { let windowS = 5 * 60 var out: [HrvWindow] = [] var t = start - while t < end { - let bucket = seg.filter { $0.ts >= t && $0.ts < t + windowS }.map { Double($0.rrMs) } + repeat { + // Final window closes on `end` — same closed-window rule as sessionRestingHR, so an + // endpoint beat counts instead of vanishing after admission. `repeat` runs once for a + // zero-length window, where that single closed window is the whole session. + let isFinal = t + windowS >= end + let bucket = seg.filter { $0.ts >= t && (isFinal || $0.ts < t + windowS) }.map { Double($0.rrMs) } // Full clean (range + Malik ectopic rejection), not just range — matches the // analyze() pipeline. The 0x2A37 RR on a WHOOP 5/MG is PPG-derived and noisier // than a 4.0's; rMSSD is built from SUCCESSIVE differences, so an un-rejected @@ -2495,7 +2960,7 @@ public enum SleepStager { let stage = stages.first { center >= $0.start && center < $0.end }?.stage ?? "?" out.append(HrvWindow(startTs: t, stage: stage, cleanBeats: cleaned.nn.count, rmssd: rmssd)) t += windowS - } + } while t < end return out } diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SlidingStreamWindow.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SlidingStreamWindow.swift new file mode 100644 index 0000000000..fa73ae5dbf --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SlidingStreamWindow.swift @@ -0,0 +1,129 @@ +import Foundation + +/// One stream's backward sliding read buffer for `analyzeRecent`'s pass-1 loop (#1538). +/// +/// `WindowedStreamPlan` decides WHETHER a day's window can reuse what the previous day read; this holds +/// the rows and does the splice. Kept out of the engine because the splice is the part that can silently +/// be wrong — a row dropped here is a scoring input that vanishes — so it gets its own tests rather than +/// living inline in a very long loop. Twin of Kotlin `SlidingStreamWindow`. +/// +/// The buffer always ends up covering EXACTLY the window just served, never more: the walk runs backwards +/// so the tail above `to` is never asked for again, and holding it would grow the footprint across the +/// pass instead of keeping it at one window. Rows are stored in the store's own `ts ASC` order and an +/// extension is always strictly BELOW the buffer, so prepending preserves that order without a sort. +public final class SlidingStreamWindow { + + private let tsOf: (T) -> Int + private let limit: Int + /// The store call for `(owner, from, to)`. Bound at CONSTRUCTION to match the Kotlin twin, where the + /// two call sites sit inside a method already at its JVM bytecode budget and a per-call lambda put it + /// over. It must return `ts ASC` and honour the same `limit`. + /// + /// `nil` means the read FAILED, which is not the same as returning no rows and must never be cached as + /// if it were. An empty successful read is a true statement about a range — the buffer can serve it, + /// and the next day may splice against it. An empty FAILED read is no statement at all, and caching it + /// would let the following day splice against rows nobody ever fetched, silently dropping the part of + /// its window the buffer claimed to hold. This side is the one that needs it: the engine wraps its + /// store reads in `try?`, so a failure arrives here as an empty array unless the caller says otherwise. + private let read: (String, Int, Int) async -> [T]? + + private var owner: String? + private var from = 0 + private var to = 0 + private var truncated = false + private var rows: [T] = [] + + /// Rows this window served from the buffer rather than reading. Diagnostic only. + /// + /// `Int` here, `Long` on the Kotlin twin, and that is deliberate rather than drift: each side follows + /// its own platform's convention for a count, and the value cannot reach either limit — a pass is + /// bounded by `maxDays` windows of at most `limit` rows, so about 4 M on a 21-day pass, which fits + /// even the 32-bit `Int` of `arm64_32` that this package also builds for. Stated because a width left + /// to be inferred is how the 32-bit `pct` over-count got in (#1685). + public private(set) var rowsServed = 0 + /// Rows this window read from the store. Diagnostic only. Same width note as `rowsServed`. + public private(set) var rowsRead = 0 + /// Reads whose RESULT came back at the store's cap, so the newest rows were dropped and the day was + /// scored on an incomplete window. Diagnostic only, but unlike its siblings this one is a correctness + /// signal rather than a savings one: a non-zero count means a number may be wrong, not merely slow. + /// + /// Counted per WINDOW that lost rows, not per pass: once a read is truncated the planner refuses to + /// splice at all, so each later window is a fresh full read and is judged on its own. The truncated- + /// EXTENSION branch below cannot add a second count for one window either, since it only ever re-reads + /// a SUPERSET of what already overran the cap. Pinned by `eachTruncatedWindowCountsSeparately`. + public private(set) var truncatedReads = 0 + + public init(tsOf: @escaping (T) -> Int, limit: Int, read: @escaping (String, Int, Int) async -> [T]?) { + self.tsOf = tsOf + self.limit = limit + self.read = read + } + + /// The rows for `[from, to]` under `owner`, reading as little as the plan allows. + /// + /// The result is byte-for-byte what a direct `read(owner, from, to)` would have returned — that is the + /// whole contract, and the reason every case the planner cannot prove falls back to exactly that call. + public func rows(owner: String, from: Int, to: Int) async -> [T] { + let plan = WindowedStreamPlan.plan(cachedOwner: self.owner, cachedFrom: self.from, + cachedTo: self.to, cachedTruncated: truncated, + owner: owner, from: from, to: to) + let result: [T] + let nowTruncated: Bool + switch plan { + case .serve: + result = rows.filter { tsOf($0) >= from && tsOf($0) <= to } + nowTruncated = false + rowsServed += result.count + case let .extend(readFrom, readTo): + guard let head = await read(owner, readFrom, readTo) else { return failedRead() } + rowsRead += head.count + if head.count >= limit { + // A truncated EXTENSION is worse than a truncated buffer: it would leave a hole in the + // middle rather than at the end. Discard and read the window whole. + guard let full = await read(owner, from, to) else { return failedRead() } + rowsRead += full.count + result = full + nowTruncated = full.count >= limit + } else { + // Filtered into ONE array rather than `(head + rows).filter { }`: that form allocates + // the concatenation AND the filtered copy, so the splice transiently held roughly three + // windows' worth of references. Same result, one allocation. + var out: [T] = [] + out.reserveCapacity(head.count + rows.count) + for r in head where tsOf(r) >= from && tsOf(r) <= to { out.append(r) } + for r in rows where tsOf(r) >= from && tsOf(r) <= to { out.append(r) } + result = out + nowTruncated = false + rowsServed += max(0, result.count - head.count) + } + case .fullRead: + guard let full = await read(owner, from, to) else { return failedRead() } + rowsRead += full.count + result = full + nowTruncated = full.count >= limit + } + self.owner = owner + self.from = from + self.to = to + self.truncated = nowTruncated + if nowTruncated { truncatedReads += 1 } + self.rows = result + return result + } + + /// A failed read leaves NO buffer: the next day must go back to the store rather than splice against a + /// window nobody successfully filled. Returns empty, which is what the caller saw for a failed read + /// before this class existed. + private func failedRead() -> [T] { + owner = nil + // The range is cleared too, not just the owner. Leaving it stale would work only because + // `WindowedStreamPlan.plan` happens to test the owner BEFORE the bounds — a cross-file assumption + // that holds today and would break silently if those checks were ever reordered. Cheaper to be + // self-consistent than to depend on the order of someone else's guards. + from = 0 + to = 0 + rows = [] + truncated = false + return [] + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SseDeltas.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SseDeltas.swift new file mode 100644 index 0000000000..d50ac9fd5e --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SseDeltas.swift @@ -0,0 +1,126 @@ +import Foundation + +// SseDeltas.swift — pure SSE delta parsing for AI provider streaming. +// +// The verifiable core of K1 (streaming): given one SSE `data:` payload, extract the text delta +// for the provider's wire shape. Pure + deterministic so it is unit-testable without a network, +// and byte-identical to the Android twin `com.noop.analytics.SseDeltas`. The streaming HTTP glue +// (URLSession.bytes / OkHttp source) is thin and calls into these pure functions per line. +// +// No network, no Foundation URL loading, no I/O — only JSONSerialization (Foundation core, available +// in the package test target on macOS + Linux). The anonymity / no-raw-egress posture is unchanged: +// streaming changes transport, not payload. + +/// Pure SSE delta extraction for the three provider wire shapes the coach speaks. +/// Byte-twin of Android `com.noop.analytics.SseDeltas`. +public enum SseDeltas { + + /// The SSE stream terminator sent by OpenAI-compatible servers. + public static let doneMarker = "[DONE]" + + // MARK: - SSE line splitting + + /// Strip the `data: ` prefix from one SSE line and return the raw payload, or nil for blank + /// lines (event separators), comments (`: …`), or non-data fields (`event:`, `id:`, etc.). + /// Pure; byte-twin of Android `dataPayload(fromLine:)`. + public static func dataPayload(fromLine line: String) -> String? { + // SSE lines are terminated by \n; the caller splits on \n. A trailing \r (CRLF streams) + // must be stripped — Kotlin's trim() removes \r, so .whitespacesAndNewlines keeps parity. + // A blank line is an event separator — no payload. A line starting with ":" is a comment. + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return nil } + if trimmed.hasPrefix(":") { return nil } + // "data:" or "data: " — strip the prefix, then one optional leading space (SSE spec: the + // space after the colon is not part of the value). + if trimmed.hasPrefix("data:") { + var payload = String(trimmed.dropFirst("data:".count)) + if payload.hasPrefix(" ") { payload = String(payload.dropFirst()) } + return payload + } + // Other SSE fields (event:, id:, retry:) are not data — skip. + return nil + } + + /// True when a data payload is the `[DONE]` terminator (OpenAI-compatible). Pure. + public static func isDone(_ payload: String) -> Bool { + payload.trimmingCharacters(in: .whitespacesAndNewlines) == doneMarker + } + + // MARK: - Per-provider delta extraction + + /// Extract the text delta from an OpenAI-compatible SSE data payload. + /// Shape: `{"choices":[{"delta":{"content":"hello"}}]}` → `"hello"`. + /// Returns nil for a `[DONE]` line, a payload with no choices, or a non-content delta (e.g. + /// the `role` delta on the first chunk). Pure; byte-twin of Android `openAiDelta`. + public static func openAiDelta(_ payload: String) -> String? { + if isDone(payload) { return nil } + guard let data = payload.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let choices = obj["choices"] as? [[String: Any]], + let first = choices.first, + let delta = first["delta"] as? [String: Any], + let content = delta["content"] as? String else { return nil } + // Kotlin twin returns null for empty content (optString.takeIf { isNotEmpty() }), so + // onDelta does not fire for OpenAI's usual first delta `"content": ""`. Returning "" + // here would fire onDelta on Swift but not Kotlin — breaks K14 haptic parity. + return content.isEmpty ? nil : content + } + + /// Extract the text delta from a Gemini SSE data payload (`:streamGenerateContent?alt=sse`). + /// Shape: `{"candidates":[{"content":{"parts":[{"text":"hello"}]}}]}` → `"hello"`. + /// Gemini streams full candidate objects per chunk; each may carry multiple parts. Returns nil + /// for a chunk with no text parts (e.g. the final `finishReason` chunk). Pure; byte-twin of + /// Android `geminiDelta`. + public static func geminiDelta(_ payload: String) -> String? { + guard let data = payload.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let candidates = obj["candidates"] as? [[String: Any]], + let first = candidates.first, + let content = first["content"] as? [String: Any], + let parts = content["parts"] as? [[String: Any]] else { return nil } + let text = parts.compactMap { $0["text"] as? String }.joined() + return text.isEmpty ? nil : text + } + + /// Extract the text delta from an Anthropic SSE data payload. + /// Shape: `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}}` + /// → `"hello"`. Returns nil for non-text-delta events (`message_start`, `ping`, `message_stop`, + /// `content_block_start`, etc.). Pure; byte-twin of Android `anthropicDelta`. + public static func anthropicDelta(_ payload: String) -> String? { + guard let data = payload.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let type = obj["type"] as? String, + type == "content_block_delta", + let delta = obj["delta"] as? [String: Any], + let deltaType = delta["type"] as? String, + deltaType == "text_delta", + let text = delta["text"] as? String else { return nil } + // Kotlin twin returns null for empty text — parity with openAiDelta above. + return text.isEmpty ? nil : text + } + + // MARK: - Full-stream reassembly (test helper) + + /// Reassemble the full reply text from a complete SSE stream (all lines, in order) for the + /// given provider shape. Pure; used by tests to assert the streamed text equals the non-streamed + /// reply. Byte-twin of Android `reassemble`. + public static func reassemble(lines: [String], provider: SseProvider) -> String { + var out = "" + for line in lines { + guard let payload = dataPayload(fromLine: line) else { continue } + let delta: String? + switch provider { + case .openAi, .custom: delta = openAiDelta(payload) + case .gemini: delta = geminiDelta(payload) + case .anthropic: delta = anthropicDelta(payload) + } + if let d = delta { out += d } + } + return out + } +} + +/// The provider wire shape to use for delta extraction. `.custom` shares the OpenAI shape. +public enum SseProvider { + case openAi, custom, gemini, anthropic +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/StepsCounter.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/StepsCounter.swift index b77c25305b..21603c60b5 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/StepsCounter.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/StepsCounter.swift @@ -4,33 +4,59 @@ import WhoopProtocol /// Wrap-aware step derivation from the strap's cumulative `step_motion_counter@57`, shared by the daily /// total (`AnalyticsEngine.analyzeDay`) and any windowed total (a manual workout's `[start, end]`, #398). /// -/// `step_motion_counter@57` is a CUMULATIVE u16 running counter: it climbs while you move, holds flat when -/// still, and wraps at 65536. The number of motion ticks over a set of time-ordered records is the SUM of -/// WRAP-AWARE increments of that counter — `delta = (cur - prev) & 0xFFFF` — with a per-user -/// `stepTicksPerStep` calibration applied by the caller AFTERWARDS (this returns the raw pre-calibration -/// tick total, so the two callers can never disagree on the counter math). The raw total is an ESTIMATE -/// (@57 counts motion ticks, not validated steps), not cloud/clinical parity. +/// `step_motion_counter@57` is a CUMULATIVE u16 motion counter: it climbs for both locomotion and some +/// non-step wrist motion, and wraps at 65536. On classed WHOOP 5/MG records, the increment ending at each +/// sample is counted only when the strap labels that sample walk (1) or run (2); still (0) and unknown are +/// rejected. A wholly unclassed legacy window retains the old counter-only estimate so pre-migration history +/// remains readable. The caller applies its per-user `stepTicksPerStep` calibration afterwards. The result is +/// still an estimate, not cloud/clinical parity. /// /// Kept byte-for-byte in lockstep with the Kotlin twin `StepsCounter.stepsInWindow`. public enum StepsCounter { - /// The largest wrap-aware increment treated as real motion between two adjacent 1 Hz records. A delta - /// at/above this is a big time-gap / disconnect boundary between sync sessions (or a firmware reboot, - /// byte-indistinguishable from a u16 wrap), NOT real steps — dropped so gaps don't inflate the total. - /// Real 1 Hz motion never ticks this fast between adjacent records. (#132/#276/#316) + private static let locomotionActivityClasses: Set = [1, 2] + + static func hasActivityClasses(_ samples: [StepSample]) -> Bool { + samples.contains { $0.activityClass != nil } + } + + /// Kotlin twin: `StepsCounter.shouldCountDelta`. + static func shouldCountDelta(activityClass: Int?, hasActivityClasses: Bool) -> Bool { + !hasActivityClasses || activityClass.map(locomotionActivityClasses.contains) == true + } + + /// Absolute reboot/wrap guard, independent from the per-second plausibility gate below. public static let maxStepDelta = 512 + public static let maxTicksPerSecond = 4 + + /// Kotlin twin: `StepsCounter.isPlausibleDelta`. + static func isPlausibleDelta(previousTs: Int, currentTs: Int, delta: Int) -> Bool { + guard delta >= 1, delta < maxStepDelta else { return false } + let elapsed = currentTs - previousTs + guard elapsed > 0 else { return false } + let rateAllowance = elapsed >= maxStepDelta / maxTicksPerSecond + ? maxStepDelta - 1 + : elapsed * maxTicksPerSecond + return delta <= rateAllowance + } - /// Raw wrap-aware motion-tick total across `samples` — the sum of positive consecutive - /// `step_motion_counter@57` increments in `[1, maxStepDelta)`. Sorts by `ts` internally, so the caller - /// may pass an unsorted window (already filtered to the range it cares about). Returns `nil` when there - /// are fewer than two samples or no forward movement (so "no data" stays distinct from a real zero). - /// The caller applies its `stepTicksPerStep` calibration to the returned ticks. + /// Raw wrap-aware locomotion-tick total across `samples`. When any sample carries `activityClass`, each + /// positive increment is attributed to the later sample and retained only for walk/run. When the whole + /// window is legacy-unclassed, all valid increments retain the historical counter-only fallback. Sorts + /// by `ts` internally and returns `nil` for fewer than two samples or no retained movement. public static func stepsInWindow(_ samples: [StepSample]) -> Int? { let sorted = samples.sorted { $0.ts < $1.ts } if sorted.count < 2 { return nil } + let hasActivityClasses = hasActivityClasses(sorted) var total = 0 for i in 1..= 1 && delta < maxStepDelta { total += delta } // ignore a delta >= 512 (gap/reset) + let isLocomotion = shouldCountDelta( + activityClass: sorted[i].activityClass, + hasActivityClasses: hasActivityClasses) + if isLocomotion && isPlausibleDelta( + previousTs: sorted[i - 1].ts, currentTs: sorted[i].ts, delta: delta) { + total += delta + } } return total > 0 ? total : nil } diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/StrainScorer.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/StrainScorer.swift index fc75a24fd1..1151d99de5 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/StrainScorer.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/StrainScorer.swift @@ -212,6 +212,7 @@ public enum StrainScorer { public static func effectiveEffort(live: Double?, stored: Double?) -> Double? { guard let live else { return stored } guard let stored else { return live } + if live == 0.0 && stored == 0.0 { return 0.0 } return Swift.max(live, stored) } @@ -346,7 +347,15 @@ public enum StrainScorer { restingHR: Double = defaultRestingHR, method: Method = .edwards, sex: String = "male", - denominator: Double? = nil) -> Double? { + denominator: Double? = nil, + // Optional diagnostic sink. Nil by default and the line is built ONLY when + // one is supplied, so a pass nobody is watching pays nothing. + // + // Supplying one BYPASSES the memo — see the guard below. Without that the + // line would usually never appear, because the Today view has already cached + // the day at live-HR tick rate before the scoring pass asks. + diag: ((String) -> Void)? = nil, + day: String = "") -> Double? { // Resolve BEFORE the memo key is built, or a Banister request would be cached under Edwards' // denominator and a later Edwards request could collide with it. let resolvedDenominator = denominator ?? logMapDenominator(method: method, sex: sex) @@ -358,12 +367,53 @@ public enum StrainScorer { hr: StreamFingerprint.of(hr, ts: { $0.ts }, quant: { Int($0.bpm) }), maxHR: maxHR, restingHR: restingHR, method: method, sexF: sex.lowercased().hasPrefix("f"), denom: resolvedDenominator) + // A diagnostic request BYPASSES the memo, and must. The Today view re-reads this on every + // live-HR tick with no sink, so by the time the scoring pass asks with one the answer is already + // cached — and a cache hit never reaches the code that emits, so the line would simply never + // appear. Recomputing one day per pass to be able to report it is the trade, and it is a cheap + // one. Kotlin has no memo here and always emits; this keeps the two behaving the same. + guard diag == nil else { + return strainUncached(hr, maxHR: maxHR, restingHR: restingHR, method: method, sex: sex, + denominator: resolvedDenominator, diag: diag, day: day) + } return strainCache.value(key) { strainUncached(hr, maxHR: maxHR, restingHR: restingHR, method: method, sex: sex, - denominator: resolvedDenominator) + denominator: resolvedDenominator, diag: nil, day: day) } } + /// One line naming what an Effort score was computed FROM, or why it could not be computed. + /// + /// The gap this closes: `strain` is the only score in the app with no trace at all. WorkoutDetector, + /// SleepStager and both engines each emit a funnel; the number on the Today hero ring emitted nothing, + /// so a log could not distinguish "measured, and the day was genuinely calm" from "could not measure". + /// A reader looking for the latter finds `workout detect`, which is WORKOUT-BOUT detection and answers + /// a different question — a confusion that has already produced one wrong diagnosis. + /// + /// `enough` is the `strain` gate spelled out: dense (>= minReadings) OR sparse-but-sustained. `trimp` + /// and `strain` are absent when the gate refused, which is exactly the case a bare 0 hides. + /// Byte-identical to the Kotlin `StrainScorer.scoreFunnelLine`. + public static func scoreFunnelLine(day: String, hrSamples: Int, enough: Bool, + maxHR: Double, maxHRProvided: Bool, restingHR: Double, + method: Method, trimp: Double?, strain: Double?) -> String { + func r1(_ v: Double) -> String { String(format: "%.1f", v) } + // Built by appending rather than as one `+` chain. A chain of interpolated segments is a single + // expression, and this one blew the type-checker's budget on the first attempt — the same failure + // that took the iOS build down in #1767. Statements give the solver one small problem at a time. + let src: String = maxHRProvided ? "provided" : "default" + let trimpText: String = trimp.map(r1) ?? "n/a" + let strainText: String = strain.map(r1) ?? "n/a" + var out = "effort score day=\(day) hr=\(hrSamples) enough=\(enough)" + out += " hrMax=\(r1(maxHR))(\(src))" + out += " rhr=\(r1(restingHR)) reserve=\(r1(maxHR - restingHR))" + // Method carries no raw value here; interpolating the case yields "edwards"/"banister", which + // is exactly what Kotlin's `method.name.lowercase()` produces. The two lines must match byte for + // byte, so this is the one spelling that keeps them equal. + out += " method=\(method)" + out += " trimp=\(trimpText) strain=\(strainText)" + return out + } + /// Key folds `sex` to the single bit the recipe reads (`hasPrefix("f")`) so "female"/"f"/"F" all hit. private struct StrainKey: Hashable { let hr: StreamFingerprint @@ -373,7 +423,8 @@ public enum StrainScorer { private static let strainCache = AnalyticsMemoCache(capacity: 48) private static func strainUncached(_ hr: [HRSample], maxHR: Double?, restingHR: Double, - method: Method, sex: String, denominator: Double) -> Double? { + method: Method, sex: String, denominator: Double, + diag: ((String) -> Void)? = nil, day: String = "") -> Double? { let effMax = maxHR ?? Double(defaultMaxHR()) // Enough data to trust the score: a dense stream (≥ minReadings) OR a sparse-but-sustained // one spanning ≥ minSpanSeconds with a sample floor (#482 — the 5/MG's ~30 s HR cadence). @@ -386,7 +437,14 @@ public enum StrainScorer { } else { enoughData = false } - if !enoughData || effMax <= restingHR { return nil } + if !enoughData || effMax <= restingHR { + // The refusal is the half a bare number cannot show: nil here and 0.0 on a calm day look + // identical on the ring, and only one of them is a measurement. + diag?(scoreFunnelLine(day: day, hrSamples: hr.count, enough: enoughData, maxHR: effMax, + maxHRProvided: maxHR != nil, restingHR: restingHR, method: method, + trimp: nil, strain: nil)) + return nil + } let durations = sampleDurationsMinutes(hr) let hrReserve = effMax - restingHR @@ -404,6 +462,10 @@ public enum StrainScorer { trimp = edwardsTRIMP(hr, restingHR: restingHR, hrReserve: hrReserve, durations: durations) } - return trimpToStrain(trimp, denominator: denominator) + let scored = trimpToStrain(trimp, denominator: denominator) + diag?(scoreFunnelLine(day: day, hrSamples: hr.count, enough: enoughData, maxHR: effMax, + maxHRProvided: maxHR != nil, restingHR: restingHR, method: method, + trimp: trimp, strain: scored)) + return scored } } diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/StreamReadCap.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/StreamReadCap.swift new file mode 100644 index 0000000000..208508ffe0 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/StreamReadCap.swift @@ -0,0 +1,112 @@ +import Foundation + +/// Per-stream read caps for the `analyzeRecent` sliding windows (#1538). +/// +/// One shared cap of 200,000 covered both heavy streams, and it was sized for HR. A field capture +/// (2026-09-05, WHOOP 5/MG, 21 nights) shows what that cost: +/// +/// hr[read=1723815 served=1826919 truncated=0] +/// rr[read=2563444 served=610905 truncated=10] +/// +/// Ten R-R windows came back AT the cap. `SlidingStreamWindow.truncatedReads` is explicit about what +/// that means - "a non-zero count means a number may be wrong, not merely slow" - because the read is +/// `ORDER BY ts ASC LIMIT`, so what gets dropped is the NEWEST rows. Every HRV number derived from one +/// of those windows was computed on a night missing its tail, silently. +/// +/// ## Why R-R and not HR +/// +/// Measured, not reasoned from beat rate - the capture contradicts the obvious argument. R-R is one row +/// per BEAT, so it "should" outrun HR's 1/s, yet a stride read in that same log returned 81,009 R-R rows +/// against 107,415 HR: R-R is recorded in BURSTS, not continuously, so its average over a window sits +/// BELOW HR's while its peaks run far above. Averages are why one cap looked safe; peaks are what +/// truncated it. +/// +/// What is certain is the behaviour: across 21 nights R-R came back at the 200,000 cap ten times and HR +/// never did. HR's own margin is the other half of the story - a full 54-hour window at 1/s is ~194,400 +/// rows, 3% under the cap, so HR was never comfortable either, merely lucky. +/// +/// The caps are therefore sized against what a window can PEAK at, not what it averages. The rate +/// constants below are deliberately generous for that reason: they are a ceiling to stay clear of, not a +/// prediction of typical density. +/// +/// So the bug was not the number being too small. It was ONE number serving two streams whose peak +/// densities differ, where a value comfortable for one is a silent truncation for the other. +/// +/// ## The invariant +/// +/// A cap must exceed what a full window can legitimately hold, or a complete read is indistinguishable +/// from a truncated one. `capExceedsFullWindow` pins exactly that, per stream, so a future change to the +/// window span or a stream's rate fails a test rather than quietly clipping a night. +/// +/// Note the cap is a ceiling, not an allocation: a read still returns only the rows that exist. Raising +/// it costs nothing on a normal night and buys correctness on a dense one. +public enum StreamReadCap { + + /// How far BEFORE the day boundary the pass reads. EVERY lookback in the engine takes this value - + /// the per-day `from`, the window start, the skin-anchor scan - so the window and the caps derived + /// from it cannot drift apart - widening the lookback there without + /// resizing the caps here is precisely how a stream starts truncating again, and is the "one number + /// in two places" shape this whole type exists because of. + public static let lookbackSeconds = 30 * 3_600 + + /// How far AFTER it, capped at `now` for a day still in progress. A whole day. + public static let forwardSeconds = 24 * 3_600 + + /// The per-day read window: `dayStart - 30h` through the night, i.e. 54 hours. + public static let windowSeconds = lookbackSeconds + forwardSeconds + + /// HR: one sample per second. + public static let hrRowsPerSecond = 1.0 + + /// R-R at its PEAK. The measured average is below 1/s (it arrives in bursts, with gaps), but bursts + /// are what reach a cap - and #1008's cross-second overcount can double the rows a burst produces. + /// 2/s is a ceiling chosen to stay clear of, not a claim about typical density. + public static let rrRowsPerSecond = 2.0 + + /// Gravity: one sample per second, like HR, and read over the SAME 54-hour window - the code's claim + /// that "the other eight streams are thousands of rows" does not hold for this one. A field capture + /// measured 192,698 gravity rows, 96% of the old shared cap. + /// + /// It is the most dangerous of the three, because it is a PLAIN read rather than a + /// `SlidingStreamWindow`: nothing counts a truncation here, so a clip would be silent even by the + /// standard that caught R-R. Sleep STAGING is what consumes it, so the cost of a clipped read is a + /// mis-staged night rather than a slow one. + public static let gravityRowsPerSecond = 1.0 + + /// Headroom over a full window, so a legitimate read cannot land ON the cap and be mistaken for a + /// truncated one. + public static let headroom = 1.5 + + /// The cap on the sleep engine's skin-temp reads, named rather than left a literal so the no-night + /// line can say when one came back AT it. Skin reads OUTSIDE the engine (a chart, an export) keep + /// their own limits - this is not a repo-wide skin cap. + /// + /// Not derived from a row rate like the caps below: skin's density was never measured. Skin rides the + /// record that carries gravity, and gravity ranged from 177 to 192,698 rows across 54 HOURS in one + /// capture, so whether 200,000 is generous or tight is genuinely unknown - which is the reason to + /// print the count. Unchanged in value: this names what both platforms already read at. + /// + /// Shared by two reads with very different spans - the night window, and the ~21-day WHOOP 4.0 anchor + /// scan. Only the window read's count reaches the no-night line, so an `atCap=skin` marker means THAT + /// read was clipped; a clipped anchor scan still goes unreported. + public static let skin = 200_000 + + /// The cap for a stream producing `rowsPerSecond` at its densest. + public static func cap(rowsPerSecond: Double) -> Int { + Int((Double(windowSeconds) * rowsPerSecond * headroom).rounded(.up)) + } + + /// 291,600 - a full HR window plus half again. + /// + /// STORED, not computed. A window's read cap and its truncation threshold must be the same number, + /// and the engine names this in both slots - a stored constant makes those two references provably + /// one value rather than two evaluations that merely ought to agree. + public static let hr = cap(rowsPerSecond: hrRowsPerSecond) + + /// 583,200 - a full R-R window at its densest, plus half again. Stored for the reason `hr` gives. + public static let rr = cap(rowsPerSecond: rrRowsPerSecond) + + /// 291,600 - the same shape as `hr`, for the same 54-hour window. Only the 54-hour read needs it; + /// the day-scoped gravity reads span 24 hours and cannot approach any of these. + public static let gravity = cap(rowsPerSecond: gravityRowsPerSecond) +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/WakeMotionRefinement.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/WakeMotionRefinement.swift index d8703124b0..26c8bcd0c1 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/WakeMotionRefinement.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/WakeMotionRefinement.swift @@ -135,8 +135,14 @@ public enum WakeMotionRefinement { let newStages = refine(session.stages, grav: grav, steps: steps) guard newStages != session.stages else { return session } let newEfficiency = SleepStager.efficiency(start: session.start, end: session.end, stages: newStages) + // `hrOnly` carried explicitly. The Kotlin twin is a `copy`, which preserves a new field for + // free; this rebuilds the struct field by field and drops one BY DEFAULT. Refinement only ever + // sees motion-detected sessions today, so an HR-only night never reaches here — but the two + // platforms would have disagreed silently the moment it did, which is the shape of divergence + // the parity contract exists to catch. return SleepSession(start: session.start, end: session.end, efficiency: newEfficiency, - stages: newStages, restingHR: session.restingHR, avgHRV: session.avgHRV) + stages: newStages, restingHR: session.restingHR, avgHRV: session.avgHRV, + hrOnly: session.hrOnly) } /// Toggle-shaped convenience for the session-level overload (see `apply(_:grav:steps:enabled:)`). diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/WindowedStreamPlan.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/WindowedStreamPlan.swift new file mode 100644 index 0000000000..05d0425bee --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/WindowedStreamPlan.swift @@ -0,0 +1,94 @@ +import Foundation + +/// How to satisfy one day's windowed stream read given what the previous day already read (#1538). +/// +/// `analyzeRecent`'s pass-1 loop walks BACKWARDS — `dayStart = nowLocalMidnight - offset * 24h` — and reads +/// a 54-hour window around each night (`dayStart - 30h` .. next local midnight). On a 24-hour stride that +/// makes consecutive windows overlap by 30 hours, so every row is materialised about 2.25 times per pass. +/// The `analyzeRecent cost prep=…ms score=…ms` line exists to decide whether that is worth narrowing, and a +/// field log answered it: prep 48129ms against score 31516ms on a cold 21-day pass, so the reads are the +/// larger half and the overlap is worth removing. +/// +/// This is the DECISION half, kept pure so both platforms can be pinned against one oracle. Splicing the +/// rows is mechanical and stays with the caller. Twin of Kotlin `WindowedStreamPlan`. +/// +/// The planner REFUSES rather than guesses. Every case it cannot prove safe returns `.fullRead`, which is +/// byte-for-byte the behaviour that shipped before this existed — the same stance `daySliceFromNight` takes +/// when it declines. An optimisation on the scoring path may only ever skip work, never change a number. +public enum WindowedStreamPlan { + + /// What the sliding windows saved, for the line emitted beside `analyzeRecent`'s `prep`/`score` + /// split — the measurement the decision to build them was made from. + /// + /// `read` is rows fetched from the store, `served` rows a buffer supplied instead. A pass where + /// `served` is ~0 means the windows are declining — truncation, an owner flip, or a gap left by a + /// dayCache hit — and the reads are back to exactly what they were. That is the honest outcome rather + /// than a silent one, which is why the counters ship rather than just the speedup. + /// + /// `truncated` separates the one of those causes that is also a BUG. The reads are + /// `ORDER BY ts ASC LIMIT`, so a read at the cap drops the NEWEST rows and the day is scored on a + /// window missing its tail, with nothing else in the log to say so. A field log showed a strap at + /// 192,680 rows against the 200,000 cap — 96% — with `served` near zero on one stream and half on the + /// other, and no way to tell which cause fired. Hence this counter rather than a guess. + /// + /// Lives HERE rather than on the engine so both platforms put it in the same place and both are + /// covered by default CI — the Swift engine is app-target and has none. Byte-identical to the Kotlin + /// `WindowedStreamPlan.logLine`. + public static func logLine( + hrRead: Int, + hrServed: Int, + hrTruncated: Int, + rrRead: Int, + rrServed: Int, + rrTruncated: Int + ) -> String { + "analyzeRecent windows hr[read=\(hrRead) served=\(hrServed) truncated=\(hrTruncated)] " + + "rr[read=\(rrRead) served=\(rrServed) truncated=\(rrTruncated)]" + } + + /// What the caller should do to obtain rows for the requested window. + public enum Plan: Equatable { + /// Read the whole window from the store; nothing usable is buffered. + case fullRead + /// The buffer already covers the window; slice it, read nothing. + case serve + /// Read only `[readFrom, readTo]` (inclusive, both ends) and prepend it to the buffer. `readTo` is + /// one second BELOW the buffer's current start, so the boundary row is never read twice — the + /// store's range predicate is inclusive at both ends. + case extend(readFrom: Int, readTo: Int) + } + + /// Plan the read for `[from, to]` against a buffer holding `[cachedFrom, cachedTo]` for `cachedOwner`. + /// + /// `cachedOwner` nil means nothing is buffered. `cachedTruncated` means the buffered read hit the + /// store's row limit, so its contents do not represent its range — that buffer can never be sliced, for + /// the reason `daySliceFromNight` gives. + /// + /// After the caller acts on the plan the buffer covers exactly `[from, to]`: rows above `to` are + /// dropped, which is what keeps the peak footprint at one window rather than the whole pass. A backward + /// walk never asks for them again. + public static func plan(cachedOwner: String?, + cachedFrom: Int, + cachedTo: Int, + cachedTruncated: Bool, + owner: String, + from: Int, + to: Int) -> Plan { + // Nothing buffered, a different strap owns this day, or the buffered read was cut off at the row + // limit — none of these can be sliced, and a wrong slice is a wrong score. + guard let cachedOwner, cachedOwner == owner, !cachedTruncated else { return .fullRead } + // A degenerate or inverted buffer holds nothing meaningful. + if cachedFrom > cachedTo { return .fullRead } + // An inverted request is not this planner's business to repair. + if from > to { return .fullRead } + // The buffer covers the request outright. + if from >= cachedFrom && to <= cachedTo { return .serve } + // The ONLY extension this walk produces: the window moved earlier, and its tail is still inside + // what is buffered. Anything else (a window that moved forward, or one disjoint from the buffer) + // is not the backward stride this exists for, so it reads in full rather than being reasoned about. + if from < cachedFrom && to >= cachedFrom && to <= cachedTo { + return .extend(readFrom: from, readTo: cachedFrom - 1) + } + return .fullRead + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift index f0266894ef..1029bb5a05 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift @@ -203,7 +203,7 @@ public enum WorkoutDetector { /// Byte-identical string to the Kotlin twin. No PII: a day key and counts, plus the two bpm thresholds /// the day was measured against — the same privacy class as the sibling `sleep day=` line. public static func detectionFunnelLine(day: String, funnel f: DetectionFunnel) -> String { - "effort detect day=\(day) hr=\(f.hrSamples) motion=\(f.motionSamples) " + "workout detect day=\(day) hr=\(f.hrSamples) motion=\(f.motionSamples) " + "restHR=\(round0(f.restingHR)) floor=\(round0(f.hrFloor)) " + "motionOK=\(f.motionPassed) hrMissing=\(f.hrMissing) hrTooLow=\(f.hrTooLow) " + "active=\(f.active) runs=\(f.runs) bridged=\(f.bridged) " @@ -578,6 +578,17 @@ public enum WorkoutDetector { /// APPROXIMATE — not laboratory calorimetry, not medical advice. public enum Calories { + /// Whole-day energy split. `restingKcal` is the BMR contribution over supported sample intervals; + /// `activeKcal` is energy above that resting floor during supported high-HR intervals. + /// `totalKcal` is the backward-compatible value persisted in `DailyMetric.activeKcalEst`. + public struct DayEnergyEstimate: Equatable, Sendable { + public let restingKcal: Double + public let activeKcal: Double + public let observedSeconds: Double + + public var totalKcal: Double { restingKcal + activeKcal } + } + struct Coeffs { let restingAlpha: Double let restingWeight: Double @@ -625,6 +636,14 @@ public enum Calories { /// Keytel is appropriate for a real detected/manual workout — but the day path raises /// the gate to 50% HRR so the gross rate only applies at genuine exercise-level HR. static let dayActiveHRRFraction = 0.50 + /// Longest gap over which one daily HR reading may carry RESTING energy. + /// + /// Resting metabolism continues across a dropout, so a reading may carry the BMR rate into the gap + /// — but only so far, or a disconnected evening would bank a full night of resting kcal that was + /// never observed. ACTIVE energy is capped separately and much tighter, at the inferred cadence + /// (see `estimateDayEnergy`): a gap is evidence of a missing sensor, never of exercise. + static let dayMaxObservedGapS: Double = 60.0 + static let dayMaxObservedSpanS: Double = 86_400.0 static let workoutDivisor = 251.04 // 60 s/min × 4.184 kJ/kcal static func resolveCoeffs(_ sex: String) -> Coeffs { @@ -725,10 +744,9 @@ public enum Calories { return (totalKcal, totalKcal * 4.184) } - /// APPROXIMATE whole-day total energy estimate (kcal) from the full day's HR samples. - /// Per-second model: below the day activeThreshold (resting + `dayActiveHRRFraction` - /// HRR) a sample burns the resting BMR rate, above it the Keytel active rate — FLOORED - /// at the resting rate so a day-second can never be credited LESS than resting metabolism. + /// APPROXIMATE whole-day resting + active energy estimate from the full day's HR samples. + /// Resting BMR is integrated once over capped, supported sample intervals, independent of HR cadence. + /// Supported high-HR intervals then add only the Keytel energy ABOVE that resting floor. /// /// The day path uses `dayActiveHRRFraction` (50% HRR), NOT the 30% the bout detector uses /// (`activeHRRFraction`). The Keytel 2005 equation is validated for genuine EXERCISE HR; @@ -738,19 +756,21 @@ public enum Calories { /// the gross rate for genuine exercise-level HR only; the bout path is UNCHANGED — Keytel /// is appropriate there, on a real detected/manual workout. /// - /// Each HR sample = ONE second of data (1 Hz strap), counted flat — this path deliberately - /// does NOT use the bout estimator's elapsed-time-per-sample weighting. The day feed is a - /// raw, non-gap-filled union of the day's HR (it is NOT motion-gated the way a bout is), so - /// capping each gap at mergeGapS (150 s) would credit up to ~150 s of active burn to a - /// single isolated elevated sample — over-counting by ~150x on gappy days. Flat - /// one-second-per-sample is the conservative, stable choice for the day total. + /// Cadence is inferred from the median positive timestamp gap and capped at + /// `dayMaxObservedGapS`. Each sample carries resting energy, and a high-HR sample carries active + /// energy, to the next sample for at most that cap. This can credit up to 60 s instead of the old + /// flat 1 s for a reading on a gappy day, but never a whole disconnect. Thus a 30 s sparse stream + /// and a 1 Hz stream covering the same activity produce comparable energy. + /// /// This is an on-device estimate from heart rate alone — NOT laboratory calorimetry, NOT - /// Apple/WHOOP cloud parity, NOT medical advice. Returns total estimated kcal (>= 0). - public static func estimateDayCalories(_ hrSamples: [HRSample], - profile: UserProfile, - hrmax: Double?, - restingHR: Double?) -> Double { - if hrSamples.isEmpty { return 0.0 } + /// Apple/WHOOP cloud parity, NOT medical advice. + public static func estimateDayEnergy(_ hrSamples: [HRSample], + profile: UserProfile, + hrmax: Double?, + restingHR: Double?) -> DayEnergyEstimate { + if hrSamples.isEmpty { + return DayEnergyEstimate(restingKcal: 0, activeKcal: 0, observedSeconds: 0) + } let weightKg = profile.weightKg > 0 ? profile.weightKg : 70.0 let heightCm = profile.heightCm > 0 ? profile.heightCm : 170.0 @@ -768,19 +788,61 @@ public enum Calories { // restingHR → base model, unchanged. Constant across the day. let vo2max = vo2maxFor(hrmax: effHRmax, restingHR: restingHR) - var totalKcal = 0.0 - for s in hrSamples { - let bpm = Double(s.bpm) - if bpm < activeThreshold { - totalKcal += restingRate + // Ties are REACHABLE: hrSample is keyed (deviceId, ts) and the day feed unions devices, so a + // two-strap day carries two readings for the same second. Only the LAST of a tied run gets the + // interval (the earlier ones measure a zero gap), so tie order decides the day's active energy. + // Ordering ties by DESCENDING bpm hands the interval to the LOWER reading — the conservative + // direction for a path whose history is over-counting. Sorting on ts alone left this to the + // sort's stability, which Kotlin guarantees (TimSort) and Swift explicitly does not. + let ordered = hrSamples.sorted { $0.ts != $1.ts ? $0.ts < $1.ts : $0.bpm > $1.bpm } + let positiveGaps = zip(ordered, ordered.dropFirst()) + .map { pair in Double(pair.1.ts - pair.0.ts) } + .filter { $0 > 0 } + .sorted() + let nominalSampleS: Double = { + guard !positiveGaps.isEmpty else { return 1.0 } + let mid = positiveGaps.count / 2 + let median = positiveGaps.count.isMultiple(of: 2) + ? (positiveGaps[mid - 1] + positiveGaps[mid]) / 2.0 + : positiveGaps[mid] + return min(median, dayMaxObservedGapS) + }() + let observedSeconds = min(dayMaxObservedSpanS, positiveGaps.reduce(nominalSampleS) { + $0 + min($1, dayMaxObservedGapS) + }) + let restingKcal = restingRate * observedSeconds + + var activeKcal = 0.0 + for i in ordered.indices { + let durationS: Double + // Active carry is capped at the INFERRED CADENCE, not at the wider resting cap. A 30 s + // stream still carries its full 30 s (the cadence bug this fixes), and a 1 Hz day carries + // 1 s so the legacy total is reproduced exactly — but a dropout in an otherwise dense day + // no longer credits a full minute of exercise to the last reading before it. That was the + // objection the flat one-second model was written to avoid, and capping active at the + // cadence answers it rather than narrowing it from 150 s to 60 s. + if i < ordered.count - 1 { + let gap = Double(ordered[i + 1].ts - ordered[i].ts) + durationS = gap > 0 ? min(gap, nominalSampleS) : 0.0 } else { - // Floor the active rate at the resting BMR rate: a worn day-second never burns - // LESS than resting metabolism, even where the Keytel value dips low for some - // profiles just above the gate. - let active = activeKcalPerS(coeffs, hr: bpm, hrmax: effHRmax, weightKg: weightKg, age: age, vo2max: vo2max) - totalKcal += max(restingRate, active) + durationS = nominalSampleS } + let bpm = Double(ordered[i].bpm) + guard bpm >= activeThreshold else { continue } + let grossRate = activeKcalPerS(coeffs, hr: bpm, hrmax: effHRmax, + weightKg: weightKg, age: age, vo2max: vo2max) + activeKcal += max(0.0, grossRate - restingRate) * durationS } - return totalKcal + return DayEnergyEstimate(restingKcal: restingKcal, activeKcal: activeKcal, + observedSeconds: observedSeconds) + } + + /// Backward-compatible total-kcal facade for the stored daily metric. + public static func estimateDayCalories(_ hrSamples: [HRSample], + profile: UserProfile, + hrmax: Double?, + restingHR: Double?) -> Double { + estimateDayEnergy(hrSamples, profile: profile, hrmax: hrmax, + restingHR: restingHR).totalKcal } } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AdaptiveExpenditureEngineTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AdaptiveExpenditureEngineTests.swift new file mode 100644 index 0000000000..98d483807b --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AdaptiveExpenditureEngineTests.swift @@ -0,0 +1,127 @@ +import Foundation +import XCTest +@testable import StrandAnalytics + +/// TDEE by energy balance — the twin of the Kotlin `AdaptiveExpenditureEngineTest`, asserting the same +/// cases with the same inputs so the two cannot drift. +/// +/// The identity is `expenditure = intake - stored-energy change`. Most of what matters here is refusing +/// to answer: the method is meaningful only over weeks, and most installs will never log enough intake. +final class AdaptiveExpenditureEngineTests: XCTestCase { + + /// A synthetic history: `days` long, intake every `logEvery` days, weight every `weighEvery`. + private func history(days: Int, intake: Double = 2_400, startKg: Double = 80, + kgPerDay: Double = 0, logEvery: Int = 1, + weighEvery: Int = 3) -> [AdaptiveExpenditureDay] { + (0.. String { + var c = DateComponents(); c.year = 2026; c.month = 1; c.day = 1 + offset + var cal = Calendar(identifier: .gregorian) + cal.timeZone = TimeZone(identifier: "UTC")! + let d = cal.date(from: c)! + let f = DateFormatter() + f.calendar = cal; f.timeZone = cal.timeZone + f.locale = Locale(identifier: "en_US_POSIX"); f.dateFormat = "yyyy-MM-dd" + return f.string(from: d) + } + + /// Weight flat ⇒ expenditure equals intake. The simplest reading of the identity. + func testStableWeightPutsExpenditureAtIntake() throws { + let est = try XCTUnwrap(AdaptiveExpenditureEngine.estimate(days: history(days: 30))) + XCTAssertEqual(est.estimatedDailyKcal, 2_400, accuracy: 1) + XCTAssertEqual(est.weightSlopeKgPerDay, 0, accuracy: 1e-9) + } + + /// Losing weight ⇒ expenditure EXCEEDED intake, so the estimate is ABOVE what was eaten. The sign is + /// the classic error in this formula, so both directions are pinned rather than one. + func testLosingWeightPutsExpenditureAboveIntake() throws { + let est = try XCTUnwrap(AdaptiveExpenditureEngine.estimate(days: history(days: 30, kgPerDay: -0.05))) + // 0.05 kg/day * 7700 = 385 kcal/day of stored energy released. + XCTAssertEqual(est.estimatedDailyKcal, 2_400 + 385, accuracy: 2) + XCTAssertGreaterThan(est.estimatedDailyKcal, est.meanIntakeKcal) + } + + /// And the mirror: gaining weight means expenditure fell SHORT of intake. + func testGainingWeightPutsExpenditureBelowIntake() throws { + let est = try XCTUnwrap(AdaptiveExpenditureEngine.estimate(days: history(days: 30, kgPerDay: 0.05))) + XCTAssertEqual(est.estimatedDailyKcal, 2_400 - 385, accuracy: 2) + XCTAssertLessThan(est.estimatedDailyKcal, est.meanIntakeKcal) + } + + /// Under three weeks the method is not meaningful, so it declines rather than answering. + func testShortHistoryYieldsNothing() { + XCTAssertNil(AdaptiveExpenditureEngine.estimate(days: history(days: 20))) + } + + /// The gate that matters most in practice: someone logs hard for a fortnight of a long window and + /// stops. The skipped days are not a random sample of their eating, so a mean over them would read + /// as a large false deficit. + func testSparseIntakeLoggingYieldsNothing() { + XCTAssertNil(AdaptiveExpenditureEngine.estimate(days: history(days: 40, logEvery: 3))) + } + + /// Weight is the other half of the identity; too few readings cannot establish a trend. + func testTooFewWeighInsYieldsNothing() { + XCTAssertNil(AdaptiveExpenditureEngine.estimate(days: history(days: 30, weighEvery: 10))) + } + + /// The output is a RANGE, and the estimate must sit inside it. + func testEstimateIsBracketedByItsInterval() throws { + let est = try XCTUnwrap(AdaptiveExpenditureEngine.estimate(days: history(days: 30))) + XCTAssertLessThan(est.lowerKcal, est.estimatedDailyKcal) + XCTAssertGreaterThan(est.upperKcal, est.estimatedDailyKcal) + } + + /// Confidence rises with the window, coverage and weigh-ins — never with the value itself. + func testConfidenceReflectsCoverageNotTheAnswer() throws { + let full = try XCTUnwrap(AdaptiveExpenditureEngine.estimate(days: history(days: 35, weighEvery: 2))) + XCTAssertEqual(full.confidence, .high) + let thin = try XCTUnwrap(AdaptiveExpenditureEngine.estimate(days: history(days: 22, weighEvery: 3))) + XCTAssertNotEqual(thin.confidence, .high) + } + + /// The window must hold exactly the days it claims. `dayCount` is inclusive, so a strict `<` in the + /// recency filter drops the oldest day while still reporting the full window — losing a day of data + /// and understating coverage, both silently. Every other case here logs every day, so a one-day loss + /// crosses no gate and nothing else would notice. + func testFullyLoggedWindowKeepsEveryDayItReports() throws { + let est = try XCTUnwrap(AdaptiveExpenditureEngine.estimate(days: history(days: 30))) + XCTAssertEqual(est.windowDays, 30) + XCTAssertEqual(est.intakeDays, est.windowDays) + } + + /// A duplicated day must not buy confidence. Coverage is "share of the window that was logged", so + /// it cannot exceed 1 — but a caller that merged its two sparse series badly could pass a day twice, + /// and an unclamped ratio above 1 both shrinks the interval and lifts the confidence, making the + /// answer look more certain than its data. That is the one direction this engine must never err in. + func testDuplicatedDaysCannotBuyANarrowerIntervalOrHigherConfidence() throws { + let clean = history(days: 30) + let duped = clean + clean // every day twice + let a = try XCTUnwrap(AdaptiveExpenditureEngine.estimate(days: clean)) + let b = try XCTUnwrap(AdaptiveExpenditureEngine.estimate(days: duped)) + XCTAssertEqual(a.upperKcal - a.lowerKcal, b.upperKcal - b.lowerKcal, accuracy: 1e-6, + "a duplicate must not narrow the interval") + XCTAssertEqual(a.confidence, b.confidence, "nor raise the confidence") + XCTAssertLessThan(b.lowerKcal, b.estimatedDailyKcal) + XCTAssertGreaterThan(b.upperKcal, b.estimatedDailyKcal) + } + + /// A bad day key must not be read as 1970 and stretch the window by twenty thousand days. + func testUnparseableDayKeyIsRefusedNotTreatedAs1970() { + XCTAssertNil(AdaptiveExpenditureEngine.dayCount(from: "not-a-day", to: "2026-01-01")) + XCTAssertNil(AdaptiveExpenditureEngine.dayCount(from: "2026-01-01", to: "")) + } + + /// Readings that all share one day cannot yield a slope; zero variance must return nil. + func testSingleDayWeightClusterHasNoSlope() { + XCTAssertNil(AdaptiveExpenditureEngine.leastSquaresSlope([(3, 80.0), (3, 80.5), (3, 79.5)])) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AlarmReadbackTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AlarmReadbackTests.swift new file mode 100644 index 0000000000..a54ca57792 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AlarmReadbackTests.swift @@ -0,0 +1,65 @@ +import XCTest +@testable import StrandAnalytics + +/// #1706. The values here are the ones from the field log that produced the issue: an arm sent +/// 2026-08-26 06:30 against a readback claiming 2045-06-10, on a phone with a 4.0 and a 5.0 registered. +/// Twin of Kotlin `AlarmReadbackTest`. +final class AlarmReadbackTests: XCTestCase { + + private let sent = 1_787_682_600 // 2026-08-26 06:30 +12:00 + private let reported = 2_380_672_980 // 2045-06-10 14:03 +12:00, what the strap reported back + + func testSameStrapAndAgreeing() { + XCTAssertEqual(AlarmReadback.verdict(sentEpoch: sent, reportedEpoch: sent + 5, + sentDeviceId: "whoop-a", reportedDeviceId: "whoop-a"), .matches) + } + + func testSameStrapAtTheToleranceBoundary() { + XCTAssertEqual(AlarmReadback.verdict(sentEpoch: sent, reportedEpoch: sent + 120, + sentDeviceId: "whoop-a", reportedDeviceId: "whoop-a"), .matches) + XCTAssertEqual(AlarmReadback.verdict(sentEpoch: sent, reportedEpoch: sent + 121, + sentDeviceId: "whoop-a", reportedDeviceId: "whoop-a"), .mismatch) + } + + func testSameStrapAndDisagreeingIsTheOnlyRealRefusal() { + let v = AlarmReadback.verdict(sentEpoch: sent, reportedEpoch: reported, + sentDeviceId: "whoop-a", reportedDeviceId: "whoop-a") + XCTAssertEqual(v, .mismatch) + XCTAssertTrue(AlarmReadback.countsAsRejection(v)) + } + + /// The field case: the readback can only come from the 4.0, the arm went to the active 5.0. + func testCrossStrapIsNotJudged() { + let v = AlarmReadback.verdict(sentEpoch: sent, reportedEpoch: reported, + sentDeviceId: "whoop-5mg", reportedDeviceId: "my-whoop") + XCTAssertEqual(v, .differentStrap) + XCTAssertFalse(AlarmReadback.countsAsRejection(v), "a strap that was never asked must not be blamed") + XCTAssertFalse(AlarmReadback.clearsRejectionStreak(v), "nor may it clear a real refusal") + } + + /// Data written before attribution existed. Unknown is not the same as innocent. + func testMissingAttributionIsNotJudged() { + let pairs: [(String?, String?)] = [(nil, "whoop-a"), ("whoop-a", nil), (nil, nil), ("", "whoop-a")] + for (a, b) in pairs { + let v = AlarmReadback.verdict(sentEpoch: sent, reportedEpoch: reported, + sentDeviceId: a, reportedDeviceId: b) + XCTAssertEqual(v, .unattributed, "\(String(describing: a)) / \(String(describing: b))") + XCTAssertFalse(AlarmReadback.countsAsRejection(v)) + XCTAssertFalse(AlarmReadback.clearsRejectionStreak(v)) + } + } + + func testOnlyAProvenAgreementClearsTheStreak() { + XCTAssertTrue(AlarmReadback.clearsRejectionStreak(.matches)) + XCTAssertFalse(AlarmReadback.clearsRejectionStreak(.mismatch)) + XCTAssertFalse(AlarmReadback.clearsRejectionStreak(.differentStrap)) + XCTAssertFalse(AlarmReadback.clearsRejectionStreak(.unattributed)) + } + + func testSuffixShape() { + XCTAssertEqual(AlarmReadback.suffix(.matches), " ✓ matches") + XCTAssertEqual(AlarmReadback.suffix(.mismatch), " ⚠️ MISMATCH — strap didn't accept the time") + XCTAssertEqual(AlarmReadback.suffix(.differentStrap), " (readback is from a different strap — not comparable)") + XCTAssertEqual(AlarmReadback.suffix(.unattributed), " (no strap recorded for one of these — not comparable)") + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineHrOnlyDayTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineHrOnlyDayTests.swift new file mode 100644 index 0000000000..c630e3a5ae --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineHrOnlyDayTests.swift @@ -0,0 +1,70 @@ +import Foundation +import XCTest +import WhoopProtocol +import WhoopStore +@testable import StrandAnalytics + +/// A whole day whose only sleep is HR-only, driven through `analyzeDay` (#1884). +/// +/// The ORACLE twin of the Kotlin `AnalyticsEngineHrOnlyDayTest`. The unit tests around this one check the +/// pieces — `SleepStagerHrOnlySessionsTests` that a session reports what it measured, +/// `HrOnlyPhysiologyIsolationTests` that the day's physiology set is chosen the way the design says. +/// Neither runs the whole function, and the gap between them is where #1884's own regression hid: +/// `sleepHrOnly` was derived from `physiologySessions.isEmpty`, which means "every session was HR-only" +/// ONLY while that set was an exclusion. Making it a preference means it can never be empty when +/// `matched` is not, so the flag would have been pinned to false forever. +/// +/// Nothing pinned that derivation: every other `sleepHrOnly` test supplies the flag as an INPUT (the +/// carry, the coalesce, the migration, the note gate). This drives it as an OUTPUT. +final class AnalyticsEngineHrOnlyDayTests: XCTestCase { + + private let profile = UserProfile(weightKg: 75, heightCm: 178, age: 30, sex: "male") + private let day = "2026-07-27" + + /// The same field-shaped generator the session tests use — 16 h awake around 74 +/- 11 bpm, then 8 h + /// asleep around 64 +/- 5 — anchored so the night runs 00:00-08:00 on `day`. NO gravity, which is what + /// makes the night HR-only: an unbonded strap streams standard HR and R-R and banks no motion. + private func streams() -> ([HRSample], [RRInterval]) { + let dayStart = AnalyticsEngine.dayStartUtcSeconds(day) + let t0 = dayStart - 16 * 3600 + var hr: [HRSample] = [] + var rr: [RRInterval] = [] + for i in 0..<(16 * 3600) { + let bpm = 74 + Int(sin(Double(i) / 500.0) * 11) + hr.append(HRSample(ts: t0 + i, bpm: bpm)) + rr.append(RRInterval(ts: t0 + i, rrMs: 60000 / bpm)) + } + for j in 0..<(8 * 3600) { + let bpm = 64 + Int(sin(Double(j) / 900.0) * 5) + hr.append(HRSample(ts: dayStart + j, bpm: bpm)) + rr.append(RRInterval(ts: dayStart + j, rrMs: 60000 / bpm)) + } + return (hr, rr) + } + + func testAllHrOnlyDayReportsMeasuredVitalsAndStillMarksItselfHrOnly() { + let (hr, rr) = streams() + // The production wiring: IntelligenceEngine stages the HR-only night and hands it to analyzeDay as + // `providedSleep`. Driving it the same way is the point — the enrichment call site that used to + // short-circuit on `hrOnly` only exists on this path. + let provided = SleepStager.hrOnlySessions(hr: hr, rr: rr, resp: []) + XCTAssertFalse(provided.isEmpty, "the HR-only spine must stage a night here") + + let res = AnalyticsEngine.analyzeDay(day: day, hr: hr, rr: rr, profile: profile, + providedSleep: provided) + + XCTAssertFalse(res.sleepSessions.isEmpty, "the staged night must reach the day's sessions") + XCTAssertTrue(res.sleepSessions.allSatisfy { $0.hrOnly }, + "every session must be HR-only — this day has no gravity at all") + + // #1884: measured, not discarded. Before the change both of these were nil by construction, which + // is what left Charge with `nilScore reason=missingInput`. + XCTAssertNotNil(res.daily.restingHr, "resting HR is HR-derived and must survive to the day row") + XCTAssertNotNil(res.daily.avgHrv, + "the HRV measured over this night's windows must survive to the day row") + + // The regression guard. `sleepHrOnly` says "every session was staged from heart rate alone", and + // that remains TRUE of this day — the change made the vitals available, not the staging better. + XCTAssertEqual(res.daily.sleepHrOnly, true, "the day must still be marked HR-only") + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineTests.swift index e8fc28bbb5..70bd0004c0 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineTests.swift @@ -474,6 +474,61 @@ final class AnalyticsEngineTests: XCTestCase { .building, "high-efficiency night with near-zero deep+REM is low-confidence staging") } + // Hypnogram coverage: a stage timeline that accounts for only part of the span it claims cannot earn + // a SOLID Rest. This is the case the other two guards structurally cannot see — see the real night + // reproduced in `testRestConfidenceCoverageCatchesTheNightH9AndGravityMiss`. + func testRestConfidenceCoverageDowngradesHoledHypnogram() { + let asleep = 8.0 * 3600.0 + XCTAssertEqual( + ScoreConfidence.rest(hasSession: true, hasStagedSleep: true, + asleepSeconds: asleep, restorativeSeconds: asleep * 0.45, + efficiency: 0.80, stageCoverage: 0.23), + .building, "a timeline covering 23% of its span cannot earn a SOLID Rest") + } + + func testRestConfidenceCoverageKeepsSolidWhenTimelineCoversItsSpan() { + let asleep = 8.0 * 3600.0 + XCTAssertEqual( + ScoreConfidence.rest(hasSession: true, hasStagedSleep: true, + asleepSeconds: asleep, restorativeSeconds: asleep * 0.45, + efficiency: 0.80, stageCoverage: 1.0), + .solid) + } + + /// Unknown coverage must fail OPEN — nil is "not measured", never "measured badly". Omitting the + /// argument entirely (every pre-existing caller) must behave identically. + func testRestConfidenceCoverageUnknownDoesNotDowngrade() { + let asleep = 8.0 * 3600.0 + for coverage in [nil, Optional(1.0)] { + XCTAssertEqual( + ScoreConfidence.rest(hasSession: true, hasStagedSleep: true, + asleepSeconds: asleep, restorativeSeconds: asleep * 0.45, + efficiency: 0.80, stageCoverage: coverage), + .solid, "coverage \(String(describing: coverage)) must not downgrade") + } + } + + /// The measured night this guard exists for (2026-08-18, Oura ring vs a paired WHOOP strap): the + /// hypnogram covered 140 of 601 minutes, so NOOP stored 70 minutes of sleep where the strap recorded + /// 494. It read SOLID because NEITHER existing guard applies — `gravitySparse` is false (Oura banks + /// no gravity at all, so `isGravitySparse` returns false for every ring night) and H9 needs + /// efficiency ≥ 0.85 against this night's 0.50. Pinned so a future refactor cannot quietly lose it. + func testRestConfidenceCoverageCatchesTheNightH9AndGravityMiss() { + let asleep = 70.0 * 60.0 + let restorative = asleep * 0.45 // ordinary share over the part that DID arrive + XCTAssertEqual( + ScoreConfidence.rest(hasSession: true, hasStagedSleep: true, + asleepSeconds: asleep, restorativeSeconds: restorative, + efficiency: 0.50, gravitySparse: false), + .solid, "precondition: without coverage this night reads SOLID — that is the bug") + XCTAssertEqual( + ScoreConfidence.rest(hasSession: true, hasStagedSleep: true, + asleepSeconds: asleep, restorativeSeconds: restorative, + efficiency: 0.50, gravitySparse: false, + stageCoverage: 140.0 / 601.0), + .building, "with coverage it is correctly low-confidence") + } + func testRestConfidenceH9KeepsSolidWhenRestorativeHealthy() { // Same high-efficiency night but a healthy ~45% restorative share → stays solid. let asleep = 8.0 * 3600.0 diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyzeRecentDayCacheStreamWitnessTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyzeRecentDayCacheStreamWitnessTests.swift new file mode 100644 index 0000000000..ecfa40ee25 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyzeRecentDayCacheStreamWitnessTests.swift @@ -0,0 +1,97 @@ +import Foundation +import XCTest +import WhoopProtocol +import WhoopStore +@testable import StrandAnalytics + +/// The per-day reuse key must witness every stream `analyzeDay` scores, not HR alone (#29). +/// +/// A WHOOP history offload does not commit its channels together: HR can land first and R-R (or +/// respiration, or SpO2) minutes later, and an offloaded HR row duplicating a live one is dropped by +/// `ON CONFLICT DO NOTHING`. So a banked night can be scored once from HR alone and then have its R-R +/// arrive with the night's HR `(count, maxTs)` completely unmoved. The whole-pass gate already sees that — +/// `WhoopStore.analysisFingerprint` covers every raw stream — so the pass runs; it was the PER-DAY key that +/// said "reuse", re-serving the HRV-less scan for the rest of the session, force refreshes included. +/// +/// `AnalyzeRecentDayCacheTests` pins the key's contract on literal witnesses. This one drives the two real +/// halves against each other — `analyzeDay` for what the night actually scores, a real store for what the +/// witness actually reports — so neither half can drift into agreeing with a broken other half. +/// Swift-only: it drives a real `WhoopStore`, and Kotlin pins the same contract from the key side in +/// `AnalyzeRecentDayCacheTest`. +final class AnalyzeRecentDayCacheStreamWitnessTests: XCTestCase { + + private let profile = UserProfile(weightKg: 75, heightCm: 178, age: 30, sex: "male") + private let day = "2026-07-27" + private let owner = "whoop-A" + + /// 16 h awake around 74 +/- 11 bpm then 8 h asleep around 64 +/- 5, anchored so the night runs + /// 00:00-08:00 on `day` — the same field-shaped generator `AnalyticsEngineHrOnlyDayTests` uses. No + /// gravity, which is the shape of the banked night in the report: a strap that streamed HR and R-R. + private func streams() -> ([HRSample], [RRInterval]) { + let dayStart = AnalyticsEngine.dayStartUtcSeconds(day) + let t0 = dayStart - 16 * 3600 + var hr: [HRSample] = [] + var rr: [RRInterval] = [] + for i in 0..<(16 * 3600) { + let bpm = 74 + Int(sin(Double(i) / 500.0) * 11) + hr.append(HRSample(ts: t0 + i, bpm: bpm)) + rr.append(RRInterval(ts: t0 + i, rrMs: 60000 / bpm)) + } + for j in 0..<(8 * 3600) { + let bpm = 64 + Int(sin(Double(j) / 900.0) * 5) + hr.append(HRSample(ts: dayStart + j, bpm: bpm)) + rr.append(RRInterval(ts: dayStart + j, rrMs: 60000 / bpm)) + } + return (hr, rr) + } + + private func scan(hr: [HRSample], rr: [RRInterval]) -> AnalyticsEngine.DayResult { + AnalyticsEngine.analyzeDay(day: day, hr: hr, rr: rr, profile: profile, + providedSleep: SleepStager.hrOnlySessions(hr: hr, rr: rr, resp: [])) + } + + /// R-R landing after HR changes what the night scores, so it must change the reuse key. + func testLateRrChangesTheNightAndInvalidatesTheKey() async throws { + let (hr, rr) = streams() + let from = hr.map(\.ts).min() ?? 0 + let to = hr.map(\.ts).max() ?? 0 + + // What the night scores. Pass 1: HR is committed, R-R has not landed. Pass 2: same HR, plus R-R. + let hrOnly = scan(hr: hr, rr: []) + let withRr = scan(hr: hr, rr: rr) + XCTAssertNil(hrOnly.daily.avgHrv, "an R-R-less night has no nightly HRV to bank") + XCTAssertNotNil(withRr.daily.avgHrv, "once R-R lands the night scores an HRV — the cached scan is stale") + + // What the store reports, over the SAME two commits. + let store = try await WhoopStore.inMemory() + try await store.upsertDevice(id: owner, mac: nil, name: nil) + _ = try await store.insert(Streams(hr: hr), deviceId: owner) + let beforeFp = try await store.hrFingerprint(deviceId: owner, from: from, to: to) + let beforeStreams = try await store.dayStreamFingerprint(deviceId: owner, from: from, to: to) + + _ = try await store.insert(Streams(rr: rr), deviceId: owner) + let afterFp = try await store.hrFingerprint(deviceId: owner, from: from, to: to) + let afterStreams = try await store.dayStreamFingerprint(deviceId: owner, from: from, to: to) + + // The HR witness alone cannot see this commit — that is the whole defect. + XCTAssertEqual(beforeFp.count, afterFp.count) + XCTAssertEqual(beforeFp.maxTs, afterFp.maxTs) + XCTAssertNotEqual(beforeStreams, afterStreams, "the R-R commit must move the other-stream witness") + + let before = AnalyzeRecentDayCache.cacheKey( + owner: owner, hrCount: beforeFp.count, hrMaxTs: beforeFp.maxTs, skinAnchorRaw: nil, + streams: beforeStreams, hrvWindowDetail: false) + let after = AnalyzeRecentDayCache.cacheKey( + owner: owner, hrCount: afterFp.count, hrMaxTs: afterFp.maxTs, skinAnchorRaw: nil, + streams: afterStreams, hrvWindowDetail: false) + XCTAssertNotEqual(before, after, + "the night's R-R arrived — reusing the HRV-less scan is the #29 defect") + + // And a pass that commits nothing must still reuse: the fix must not re-score every night forever. + let idleFp = try await store.hrFingerprint(deviceId: owner, from: from, to: to) + let idleStreams = try await store.dayStreamFingerprint(deviceId: owner, from: from, to: to) + XCTAssertEqual(after, AnalyzeRecentDayCache.cacheKey( + owner: owner, hrCount: idleFp.count, hrMaxTs: idleFp.maxTs, skinAnchorRaw: nil, + streams: idleStreams, hrvWindowDetail: false)) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyzeRecentDayCacheTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyzeRecentDayCacheTests.swift index b95f531504..f9b8910b21 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyzeRecentDayCacheTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyzeRecentDayCacheTests.swift @@ -6,18 +6,22 @@ import XCTest /// (the exact key STRING may differ across platforms — the cache is in-memory and per-platform — but the /// set of changes that must / must not invalidate a reused day is a shared contract). final class AnalyzeRecentDayCacheTests: XCTestCase { + /// A settled night's other-stream witness (`WhoopStore.dayStreamFingerprint`) — opaque to the key, so + /// these tests only need two values that differ. + private let baseStreams = "s1|p0:0|r120000:1700000000|x0:0|o0:0|g86400:1700000000|z0:0|t0:0|e0:0" + // Unchanged inputs → identical key → the day is reused. func testStableInputsReuse() { - let a = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, hrvWindowDetail: false) - let b = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, hrvWindowDetail: false) + let a = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, streams: baseStreams, hrvWindowDetail: false) + let b = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, streams: baseStreams, hrvWindowDetail: false) XCTAssertEqual(a, b) } // A new HR row (count moves) OR a later newest-ts must invalidate — the day changed. func testHrChangeInvalidates() { - let base = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, hrvWindowDetail: false) - let moreRows = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_001, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, hrvWindowDetail: false) - let laterTs = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_060, skinAnchorRaw: 1290, hrvWindowDetail: false) + let base = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, streams: baseStreams, hrvWindowDetail: false) + let moreRows = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_001, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, streams: baseStreams, hrvWindowDetail: false) + let laterTs = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_060, skinAnchorRaw: 1290, streams: baseStreams, hrvWindowDetail: false) XCTAssertNotEqual(base, moreRows) XCTAssertNotEqual(base, laterTs) } @@ -25,17 +29,17 @@ final class AnalyzeRecentDayCacheTests: XCTestCase { // A shifted window-wide skin anchor (another night's skin changed the 4.0 median) must invalidate even // when this night's HR fingerprint is identical — the skin conversion changed. func testAnchorShiftInvalidates() { - let a = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, hrvWindowDetail: false) - let b = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290.5, hrvWindowDetail: false) + let a = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, streams: baseStreams, hrvWindowDetail: false) + let b = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290.5, streams: baseStreams, hrvWindowDetail: false) XCTAssertNotEqual(a, b) } // A 5/MG night (no anchor) is a distinct, self-consistent key: nil reuses nil, and nil ≠ a real anchor // (so a 4.0 and a 5/MG night with the same fingerprint never alias to each other's scan). func testNilAnchorDistinctButStable() { - let nilA = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 5_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: nil, hrvWindowDetail: false) - let nilB = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 5_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: nil, hrvWindowDetail: false) - let real = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 5_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 0, hrvWindowDetail: false) + let nilA = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 5_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: nil, streams: baseStreams, hrvWindowDetail: false) + let nilB = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 5_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: nil, streams: baseStreams, hrvWindowDetail: false) + let real = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 5_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 0, streams: baseStreams, hrvWindowDetail: false) XCTAssertEqual(nilA, nilB) XCTAssertNotEqual(nilA, real) } @@ -45,8 +49,8 @@ final class AnalyzeRecentDayCacheTests: XCTestCase { // count+maxTs for the same window. The owner id is part of the key, so it never falsely reuses one // strap's scan for the other. func testDifferentOwnerInvalidates() { - let whoop4 = AnalyzeRecentDayCache.cacheKey(owner: "whoop4-A", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, hrvWindowDetail: false) - let whoop5 = AnalyzeRecentDayCache.cacheKey(owner: "whoop5-B", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, hrvWindowDetail: false) + let whoop4 = AnalyzeRecentDayCache.cacheKey(owner: "whoop4-A", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, streams: baseStreams, hrvWindowDetail: false) + let whoop5 = AnalyzeRecentDayCache.cacheKey(owner: "whoop5-B", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, streams: baseStreams, hrvWindowDetail: false) XCTAssertNotEqual(whoop4, whoop5) } @@ -62,10 +66,42 @@ final class AnalyzeRecentDayCacheTests: XCTestCase { func testTheHrvDetailDayIsNotReusedAsAnOrdinaryNight() { let detail = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, - hrvWindowDetail: true) + streams: baseStreams, hrvWindowDetail: true) let summary = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, - hrvWindowDetail: false) + streams: baseStreams, hrvWindowDetail: false) XCTAssertNotEqual(detail, summary, "the detail day must invalidate when it stops being today") } + + /// #29: a stream that is not HR moving must invalidate, even with the HR fingerprint frozen. + /// + /// A history offload delivers channels independently and drops offloaded HR rows that duplicate live + /// ones (`ON CONFLICT DO NOTHING`), so a night scored once from HR alone can gain its R-R — or its + /// respiration, SpO2, gravity, steps, skin temp, events, or a PPG-derived HR second — with this night's + /// `hrCount`/`hrMaxTs` completely unmoved. Keyed on HR alone that said "reuse" and re-served the + /// HRV-less scan for the rest of the session, force refreshes included. + /// + /// Byte-parity twin of Kotlin `aStreamArrivingAfterHrInvalidatesTheReusedNight`. + func testAStreamArrivingAfterHrInvalidatesTheReusedNight() { + // Same night, same HR rows, same anchor — only the other-stream witness moved. + let hrOnly = AnalyzeRecentDayCache.cacheKey( + owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, + streams: "s1|p0:0|r0:0|x0:0|o0:0|g0:0|z0:0|t0:0|e0:0", hrvWindowDetail: false) + let rrLanded = AnalyzeRecentDayCache.cacheKey( + owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, + streams: "s1|p0:0|r120000:1700000000|x0:0|o0:0|g0:0|z0:0|t0:0|e0:0", hrvWindowDetail: false) + XCTAssertNotEqual(hrOnly, rrLanded, "the night's R-R arrived — its cached scan has no HRV in it") + } + + /// The other half of the same contract: a witness that did NOT move must still reuse. #29's fix must + /// not turn every pass into a full re-score — that is the drain #1005 closed. + func testUnchangedStreamWitnessStillReuses() { + let a = AnalyzeRecentDayCache.cacheKey( + owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, + streams: baseStreams, hrvWindowDetail: false) + let b = AnalyzeRecentDayCache.cacheKey( + owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, + streams: baseStreams, hrvWindowDetail: false) + XCTAssertEqual(a, b) + } } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BackupSettingsLinuxTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BackupSettingsLinuxTests.swift new file mode 100644 index 0000000000..f88a0cc873 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BackupSettingsLinuxTests.swift @@ -0,0 +1,23 @@ +import XCTest +import WhoopStore + +/// Exercises the Corelibs Foundation NSNumber distinction used by BackupSettings on Linux. +final class BackupSettingsLinuxTests: XCTestCase { + func testBooleanAndNumericJSONValuesKeepDistinctContracts() throws { + let payload = Data(#"{"profile.age":true,"profile.hrMax":1,"profile.weightKg":false,"profile.heightCm":1}"#.utf8) + + let decoded = BackupSettings.decode(payload) + + XCTAssertNil(decoded["profile.age"], "A JSON boolean must not be accepted as numeric 1") + XCTAssertNil(decoded["profile.weightKg"], "A JSON boolean must not be accepted as numeric 0") + XCTAssertEqual(decoded["profile.hrMax"] as? Int, 1) + XCTAssertEqual(decoded["profile.heightCm"] as? Double, 1.0) + + let roundTrip = try XCTUnwrap(BackupSettings.encode(decoded)) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: roundTrip) as? [String: Any]) + XCTAssertEqual((json["profile.hrMax"] as? NSNumber)?.intValue, 1) + XCTAssertEqual((json["profile.heightCm"] as? NSNumber)?.doubleValue, 1.0) + XCTAssertNil(json["profile.age"]) + XCTAssertNil(json["profile.weightKg"]) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BaselinesRecentHrvCoverageTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BaselinesRecentHrvCoverageTests.swift new file mode 100644 index 0000000000..4d9a98c381 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BaselinesRecentHrvCoverageTests.swift @@ -0,0 +1,50 @@ +import XCTest +@testable import StrandAnalytics + +/// The counts behind "N of the last M nights recorded no HRV". +/// +/// The line only helps if both numbers are right, and both are Ints with the same units, so an inverted +/// pair reads as "5 of the last 3 nights" — absurd to a person, invisible to a compiler. These pin the +/// order as well as the arithmetic. Byte-identical twin: Kotlin `RecentHrvCoverageTest`. +final class BaselinesRecentHrvCoverageTests: XCTestCase { + + /// The reported shape: five observed nights, three of them empty. + func testCountsObservedNightsAndTheEmptyOnesAmongThem() { + let days = ["2026-09-02", "2026-09-03", "2026-09-04", "2026-09-05", "2026-09-06"] + let hrv: [Double?] = [44.0, nil, nil, 47.0, nil] + let cov = Baselines.recentHrvCoverage(dayKeys: days, nightlyHrv: hrv, today: "2026-09-06") + XCTAssertEqual(cov.observed, 5) + XCTAssertEqual(cov.missing, 3) + } + + /// Only days the app has a row for, so a fresh install is not charged for nights it never saw. + func testADayOutsideTheWindowIsNotObserved() { + let cov = Baselines.recentHrvCoverage(dayKeys: ["2026-08-01", "2026-09-06"], + nightlyHrv: [nil, nil], today: "2026-09-06", window: 14) + XCTAssertEqual(cov.observed, 1, "the August night is 36 days back, outside the window") + XCTAssertEqual(cov.missing, 1) + } + + /// A future-dated row cannot be a night that already happened. + func testADayAfterTodayIsIgnored() { + let cov = Baselines.recentHrvCoverage(dayKeys: ["2026-09-07"], nightlyHrv: [nil], + today: "2026-09-06") + XCTAssertEqual(cov.observed, 0) + XCTAssertEqual(cov.missing, 0) + } + + /// Every night counted: the caller must then say nothing extra. + func testACompleteWindowReportsNothingMissing() { + let cov = Baselines.recentHrvCoverage(dayKeys: ["2026-09-05", "2026-09-06"], + nightlyHrv: [50.0, 51.0], today: "2026-09-06") + XCTAssertEqual(cov.observed, 2) + XCTAssertEqual(cov.missing, 0) + } + + /// No rows, and an unparseable today, both yield zeroes rather than a fabricated count. + func testEmptyAndUnparseableInputsAreZero() { + XCTAssertEqual(Baselines.recentHrvCoverage(dayKeys: [], nightlyHrv: [], today: "2026-09-06").observed, 0) + XCTAssertEqual(Baselines.recentHrvCoverage(dayKeys: ["2026-09-06"], nightlyHrv: [nil], + today: "not-a-day").observed, 0) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BehaviorInsightsTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BehaviorInsightsTests.swift index 434cd453c2..a2fa1c77ab 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BehaviorInsightsTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BehaviorInsightsTests.swift @@ -14,7 +14,7 @@ final class BehaviorInsightsTests: XCTestCase { "d13": 70, "d14": 68, ] let behaviorDays: Set = ["d01", "d02", "d03", "d04", "d05", "d06"] - let e = BehaviorInsights.effect(behaviorDays: behaviorDays, outcomeByDay: outcome, + let e = BehaviorInsights.effect(behaviorDays: behaviorDays, controlDays: Set(outcome.keys).subtracting(behaviorDays), outcomeByDay: outcome, behavior: "Alcohol", outcome: "Recovery")! XCTAssertEqual(e.nWith, 6) XCTAssertEqual(e.nWithout, 8) @@ -34,6 +34,7 @@ final class BehaviorInsightsTests: XCTestCase { "f": 70, "g": 72, "h": 68, "i": 71, "j": 69, // without (mean 70) ] let e = BehaviorInsights.effect(behaviorDays: ["a", "b", "c", "d", "e"], + controlDays: Set(outcome.keys).subtracting(["a", "b", "c", "d", "e"]), outcomeByDay: outcome, behavior: "Meditation", outcome: "Recovery")! XCTAssertEqual(e.delta, 10.0, accuracy: 1e-9) @@ -46,10 +47,12 @@ final class BehaviorInsightsTests: XCTestCase { // Behavior logged every day → no "without" group. let outcome: [String: Double] = ["a": 60, "b": 61, "c": 62] XCTAssertNil(BehaviorInsights.effect(behaviorDays: ["a", "b", "c"], + controlDays: Set(outcome.keys).subtracting(["a", "b", "c"]), outcomeByDay: outcome, behavior: "X", outcome: "Recovery")) // Behavior never logged → no "with" group. XCTAssertNil(BehaviorInsights.effect(behaviorDays: [], + controlDays: Set(outcome.keys).subtracting([]), outcomeByDay: outcome, behavior: "X", outcome: "Recovery")) } @@ -58,6 +61,7 @@ final class BehaviorInsightsTests: XCTestCase { // "z" is in behaviorDays but has no outcome value → not counted in nWith. let outcome: [String: Double] = ["a": 60, "b": 62, "c": 70, "d": 72] let e = BehaviorInsights.effect(behaviorDays: ["a", "b", "z"], + controlDays: Set(outcome.keys).subtracting(["a", "b", "z"]), outcomeByDay: outcome, behavior: "X", outcome: "Recovery")! XCTAssertEqual(e.nWith, 2) // a, b only @@ -74,6 +78,7 @@ final class BehaviorInsightsTests: XCTestCase { "o1": 70, "o2": 71, "o3": 69, "o4": 70, ] let small = BehaviorInsights.effect(behaviorDays: ["w1", "w2", "w3", "w4"], + controlDays: Set(smallOutcome.keys).subtracting(["w1", "w2", "w3", "w4"]), outcomeByDay: smallOutcome, behavior: "X", outcome: "Recovery")! XCTAssertLessThan(small.pApprox, 0.05) // strong evidence numerically… @@ -86,6 +91,7 @@ final class BehaviorInsightsTests: XCTestCase { "o1": 70, "o2": 71, "o3": 69, "o4": 70, "o5": 70, ] let big = BehaviorInsights.effect(behaviorDays: ["w1", "w2", "w3", "w4", "w5"], + controlDays: Set(bigOutcome.keys).subtracting(["w1", "w2", "w3", "w4", "w5"]), outcomeByDay: bigOutcome, behavior: "X", outcome: "Recovery")! XCTAssertEqual(Swift.min(big.nWith, big.nWithout), 5) @@ -99,6 +105,7 @@ final class BehaviorInsightsTests: XCTestCase { "o1": 64, "o2": 72, "o3": 61, "o4": 74, "o5": 67, "o6": 69, ] let e = BehaviorInsights.effect(behaviorDays: ["w1", "w2", "w3", "w4", "w5", "w6"], + controlDays: Set(outcome.keys).subtracting(["w1", "w2", "w3", "w4", "w5", "w6"]), outcomeByDay: outcome, behavior: "X", outcome: "Recovery")! XCTAssertGreaterThan(e.pApprox, 0.05) // no separation → weak evidence @@ -120,7 +127,13 @@ final class BehaviorInsightsTests: XCTestCase { // Tiny-but-significant-impossible: 2 days only. let tiny: Set = ["d3", "d9"] + // Predates the Yes/No split and tests the ranking math, so it keeps its original partition + // by declaring the complement as controls explicitly. + let all = Set(outcome.keys) let ranked = BehaviorInsights.rank(behaviors: ["Strong": strong, "Weak": weak, "Tiny": tiny], + controls: ["Strong": all.subtracting(strong), + "Weak": all.subtracting(weak), + "Tiny": all.subtracting(tiny)], outcomeByDay: outcome, outcome: "Recovery") XCTAssertEqual(ranked.count, 3) XCTAssertEqual(ranked.first?.behavior, "Strong") // significant + largest |d| @@ -135,9 +148,15 @@ final class BehaviorInsightsTests: XCTestCase { func testRankDropsUncomputableBehaviors() { let outcome: [String: Double] = ["a": 60, "b": 62, "c": 70, "d": 72] // "AllDays" covers every day → no without group → dropped. + // Predates the Yes/No split and tests the ranking math, so it keeps its original partition + // by declaring the complement as controls explicitly. + let all = Set(outcome.keys) let ranked = BehaviorInsights.rank(behaviors: [ "AllDays": ["a", "b", "c", "d"], "Half": ["a", "b"], + ], controls: [ + "AllDays": all.subtracting(["a", "b", "c", "d"]), + "Half": all.subtracting(["a", "b"]), ], outcomeByDay: outcome, outcome: "Recovery") XCTAssertEqual(ranked.count, 1) XCTAssertEqual(ranked.first?.behavior, "Half") @@ -153,6 +172,7 @@ final class BehaviorInsightsTests: XCTestCase { "o1": 78, "o2": 82, "o3": 80, "o4": 79, "o5": 81, // mean 80 ] let e = BehaviorInsights.effect(behaviorDays: ["w1", "w2", "w3", "w4", "w5"], + controlDays: Set(outcome.keys).subtracting(["w1", "w2", "w3", "w4", "w5"]), outcomeByDay: outcome, behavior: "Alcohol", outcome: "Recovery")! let s = BehaviorInsights.sentence(e) @@ -165,6 +185,7 @@ final class BehaviorInsightsTests: XCTestCase { "o1": 49, "o2": 51, "o3": 50, // mean 50 ] let e = BehaviorInsights.effect(behaviorDays: ["w1", "w2", "w3"], + controlDays: Set(outcome.keys).subtracting(["w1", "w2", "w3"]), outcomeByDay: outcome, behavior: "Meditation", outcome: "Recovery")! let s = BehaviorInsights.sentence(e) @@ -179,10 +200,62 @@ final class BehaviorInsightsTests: XCTestCase { "o1": 0, "o2": 0, "o3": 0, ] let e = BehaviorInsights.effect(behaviorDays: ["w1", "w2", "w3"], + controlDays: Set(outcome.keys).subtracting(["w1", "w2", "w3"]), outcomeByDay: outcome, behavior: "X", outcome: "HRV")! XCTAssertNil(e.pctChange) let s = BehaviorInsights.sentence(e) XCTAssertEqual(s, "On days you logged ‘X’, HRV was 5.0 higher (avg 5 vs 0, n=3 vs 3).") } + // MARK: - Unlogged days are not answers (the Reddit report) + + /// "If I didn't track something for 100 days, NOOP takes that as a NO for 100 days, whereas it simply + /// was not logged at all." Reported by a user on Reddit, and it was exactly what the split did. + /// + /// Twin of Kotlin `EffectRankerTest.daysWithNoJournalRowAreNotControls`. + func testDaysWithNoJournalRowAreNotControls() { + var outcome: [String: Double] = [:] + var yes: Set = [] + var no: Set = [] + for d in 1...6 { let k = "y\(d)"; yes.insert(k); outcome[k] = Double(58 + d) } + for d in 1...6 { let k = "n\(d)"; no.insert(k); outcome[k] = Double(68 + d) } + // Never opened the journal on these, and their values sit far from BOTH answered groups. + for d in 1...40 { outcome["u\(d)"] = Double(20 + (d % 3)) } + + let e = BehaviorInsights.effect(behaviorDays: yes, controlDays: no, + outcomeByDay: outcome, + behavior: "Alcohol", outcome: "Recovery")! + // Controls are the six NO days only. Were the 40 unlogged days leaking in, nWithout would be 46 + // and meanWithout would be dragged towards 20. + XCTAssertEqual(e.nWith, 6) + XCTAssertEqual(e.nWithout, 6) + XCTAssertGreaterThan(e.meanWithout, 60.0) + } + + /// A behaviour the user only ever ticks Yes has no control group, so there is no comparison to make + /// and the honest answer is none. Twin of Kotlin `aBehaviourNeverLoggedNoYieldsNothing`. + func testBehaviourNeverLoggedNoYieldsNothing() { + var outcome: [String: Double] = [:] + var yes: Set = [] + for d in 1...10 { let k = "y\(d)"; yes.insert(k); outcome[k] = Double(50 + d) } + for d in 1...10 { outcome["u\(d)"] = Double(80 + d) } + + XCTAssertNil(BehaviorInsights.effect(behaviorDays: yes, controlDays: [], + outcomeByDay: outcome, + behavior: "Alcohol", outcome: "Recovery")) + } + + /// rank() fails CLOSED on a caller that forgets the controls: no insight, rather than a wrong one + /// measured against every day the user never opened the journal. Twin of Kotlin + /// `rankWithoutControlsProducesNothingRatherThanGuessing`. + func testRankWithoutControlsProducesNothingRatherThanGuessing() { + var outcome: [String: Double] = [:] + var yes: Set = [] + for d in 1...10 { let k = "y\(d)"; yes.insert(k); outcome[k] = Double(50 + d) } + for d in 1...20 { outcome["u\(d)"] = Double(75 + (d % 4)) } + + XCTAssertTrue(BehaviorInsights.rank(behaviors: ["Alcohol": yes], controls: [:], + outcomeByDay: outcome, outcome: "Recovery").isEmpty) + } + } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CaptureCompletenessTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CaptureCompletenessTests.swift index 51e95f0673..e36aea3eaf 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CaptureCompletenessTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CaptureCompletenessTests.swift @@ -19,7 +19,7 @@ final class CaptureCompletenessTests: XCTestCase { [import] import stage=sleep rowsIn=10 rowsOut=10 [steps] stepsRaw day=2026-06-28 counterSamples=4 deltas kept=3 dropped=0 [battery] bank soc=82.0 t=1700000000s - [recovery] charge term hrv z=0.20 w=0.40 (higher HRV is better) + [recovery] charge day=2026-06-28 term hrv z=0.20 w=0.40 (higher HRV is better) [hrv] hrv rmssd=42.10ms sdnn=55.00ms meanNN=900.00ms [universal] dayOwner day=2026-06-28 readId=my-whoop writeActiveId=my-whoop hrRows=120 provenance=measured """ @@ -126,9 +126,16 @@ final class CaptureCompletenessTests: XCTestCase { } func testTokenMapMatchesEmitterTokensExactly() { - // Guard against a silent emitter rename: each token must be the verbatim leading text the live - // emitter writes. These literals mirror the *Trace files (verified at authoring time). - XCTAssertEqual(CaptureCompleteness.expectedTokens(for: .recovery).first, "charge term") + // Guard against a silent emitter rename: each token must be the verbatim leading text that + // reaches the REPORT. + // + // "mirror the *Trace files" is how the recovery token came to be wrong for as long as it was. + // RecoveryScorer+Trace really does write "charge term ...", but IntelligenceEngine re-emits + // every one of those lines as `charge day= ` + the body before it is logged, so the + // literal this guard was protecting could not occur in a single report. A token has to mirror + // the END of the pipeline, not the start, or a guard like this passes while the thing it + // guards is dead. + XCTAssertEqual(CaptureCompleteness.expectedTokens(for: .recovery).first, "charge day=") XCTAssertTrue(CaptureCompleteness.expectedTokens(for: .hrv).contains("hrv rmssd=")) XCTAssertTrue(CaptureCompleteness.expectedTokens(for: .steps).contains("stepsRaw")) XCTAssertTrue(CaptureCompleteness.expectedTokens(for: .universal).contains("dayOwner ")) diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CircadianEngineTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CircadianEngineTests.swift index 6c2f57b17b..88cb32be18 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CircadianEngineTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CircadianEngineTests.swift @@ -129,26 +129,159 @@ final class CircadianEngineTests: XCTestCase { /// `minRelativeAmplitude` gates on `amplitude / |mesor|`. Against a signal carrying a ~45-75 bpm DC /// offset that makes the real bar an ABSOLUTE `0.10 x mesor` bpm — which these pin. /// - /// The pair that matters is the last two: the SAME 5 bpm swing is arrhythmic at a 65 bpm mesor and - /// readable at 45. The bar scales WITH the mesor, so a low-resting wearer faces a LOWER absolute - /// requirement, not a higher one. #982 raised the opposite concern — that the gate penalises the - /// fittest — and that only holds for someone whose amplitude is disproportionately small for their - /// mesor. Pinned here so the direction is a fact rather than an argument, for whoever re-tunes it. + /// These USED to pin the relative bar alone, including that the SAME 5 bpm swing was arrhythmic at a + /// 65 bpm mesor and readable at 45 — the same rhythm judged by the baseline rather than by itself. + /// `minAbsoluteAmplitudeBpm` removed that split; what remains pinned is that a proportional swing + /// still passes, a genuinely flat one still fails, and the verdict no longer moves with the mesor. private func confidence(mesor: Double, amp: Double) -> CircadianEngine.PhaseConfidence { CircadianEngine.estimatePhase(bins: profile(mesor: mesor, amp: amp, acrophase: 16), daysObserved: CircadianEngine.goodDaysForFit, habitualWakeHour: 7)!.confidence } - func testTypicalMesorNeedsAboutSixAndAHalfBpmOfSwing() { + func testAProportionalSwingStillPasses() { XCTAssertNotEqual(confidence(mesor: 65, amp: 8), .unreadable) // 0.123 - clears 0.10 - XCTAssertEqual(confidence(mesor: 65, amp: 5), .unreadable) // 0.077 - does not } - func testTheSameSwingIsReadableAtALowerMesor() { - // 5 bpm: arrhythmic at 65, rhythmic at 45. The gate favours the low-resting wearer. - XCTAssertEqual(confidence(mesor: 65, amp: 5), .unreadable) - XCTAssertNotEqual(confidence(mesor: 45, amp: 5), .unreadable) // 0.111 - XCTAssertEqual(confidence(mesor: 45, amp: 4), .unreadable) // 0.089 + /// The inconsistency the absolute floor removes: one rhythm, three baselines, one verdict. + func testTheSameSwingNoLongerDependsOnTheBaseline() { + XCTAssertNotEqual(confidence(mesor: 45, amp: 5), .unreadable) + XCTAssertNotEqual(confidence(mesor: 65, amp: 5), .unreadable) + XCTAssertNotEqual(confidence(mesor: 80, amp: 5), .unreadable) + } + + /// A genuinely flat rhythm is still refused — the floor widens the gate, it does not remove it. + func testAFlatRhythmIsStillArrhythmicOnBothMeasures() { + XCTAssertEqual(confidence(mesor: 45, amp: 4), .unreadable) // 0.089, 4.0 bpm + XCTAssertEqual(confidence(mesor: 65, amp: 4), .unreadable) // 0.062, 4.0 bpm + XCTAssertEqual(confidence(mesor: 74.7, amp: 3), .unreadable) + } + + /// The absolute floor's own boundary, isolated: at a 74.7 bpm mesor the relative test cannot pass + /// either value, so only `minAbsoluteAmplitudeBpm` decides. Expressed RELATIVE to the constant, with a + /// 0.1 bpm margin rather than the exact value — the cosinor recovers amplitude to ~1e-9, and sitting + /// exactly on `>=` would pin float recovery rather than the threshold. + func testTheAbsoluteFloorIsWhereItSays() { + let floor = CircadianEngine.minAbsoluteAmplitudeBpm + XCTAssertNotEqual(confidence(mesor: 74.7, amp: floor + 0.1), .unreadable) + XCTAssertEqual(confidence(mesor: 74.7, amp: floor - 0.1), .unreadable) + } + + /// The widening must not hand a thinner fit a FIRMER label. A rhythm admitted only by the absolute + /// floor caps at `.wide`, which withholds `chronotype` — that names a category off an acrophase a + /// small swing pins loosely. A proportional rhythm still reaches `.solid`. + func testAnAbsoluteOnlyRhythmIsReadableButNotSolid() { + XCTAssertEqual(confidence(mesor: 74.7, amp: 5.5), .wide) // 0.073 - floor only + XCTAssertEqual(confidence(mesor: 65, amp: 8), .solid) // 0.123 - proportional + let wide = CircadianEngine.estimatePhase(bins: profile(mesor: 74.7, amp: 5.5, acrophase: 16), + daysObserved: CircadianEngine.goodDaysForFit, + habitualWakeHour: 7)! + XCTAssertNil(CircadianEngine.chronotype(wide), "a floor-only fit must not name a chronotype") + } + + /// The measured wearer this change exists for: 5.5 bpm on a 74.7 bpm mesor, refused at 7.3% against + /// the 10% bar while its acrophase implied a textbook CBTmin near 04:06. + func testTheMeasuredWearerIsNoLongerSilenced() { + XCTAssertNotEqual(confidence(mesor: 74.7, amp: 5.5), .unreadable) + } + + // MARK: - chronotype lean (absolute phase, not schedule-relative) + + /// The boundaries and the circular case, asserted from this side too so the agreement is pinned on + /// both platforms rather than only in the Kotlin oracle. `late-evening-wrap` is the row that matters: + /// 23:30 is five hours BEFORE the 04:30 anchor, so it is a strong MORNING lean — a naive + /// `23.5 > 5.5` bucket would call it evening. + func testChronotypeBucketsAbsolutePhaseCircularly() { + XCTAssertEqual(CircadianEngine.chronotypeAnchorHour, 4.5, accuracy: 0, + "the anchor is derived from the engine's own constants, not hardcoded") + let cases: [(Double, CircadianEngine.Chronotype)] = [ + (4.5, .intermediate), (3.5, .intermediate), (3.49, .morning), + (5.5, .intermediate), (5.51, .evening), + (23.5, .morning), (0.0, .morning), (12.0, .evening), + (16.5, .evening), (16.4, .evening), (16.6, .morning), + (28.5, .intermediate), (-1.0, .morning), + ] + for (hour, expected) in cases { + XCTAssertEqual(CircadianEngine.chronotype(tempMinHour: hour), expected, + "tempMinHour \(hour)") + } + } + + /// A NAMED category reads as a fact about the person rather than a reading of the week, so it waits + /// for the strongest tier — unlike the continuous offset the card already shows at `.wide`. + func testChronotypeIsNamedOnlyForASolidFit() { + func estimate(_ confidence: CircadianEngine.PhaseConfidence) -> CircadianEngine.PhaseEstimate { + CircadianEngine.PhaseEstimate(tempMinHour: 23.5, acrophaseHours: 11.5, + offsetVsScheduleMinutes: 0, confidence: confidence, note: "") + } + XCTAssertEqual(CircadianEngine.chronotype(estimate(.solid)), .morning) + XCTAssertNil(CircadianEngine.chronotype(estimate(.wide)), + "a thin fit must not name a chronotype") + XCTAssertNil(CircadianEngine.chronotype(estimate(.unreadable))) + } + + /// The schedule-relative offset CANNOT name a chronotype, which is why this buckets the absolute + /// phase instead. A consistent 03:00-11:00 sleeper is well aligned with their OWN schedule — offset + /// ~0 — while being strongly evening-type by the clock. + func testConsistentLateSleeperIsEveningTypeDespiteAZeroScheduleOffset() { + let alignedButLate = CircadianEngine.PhaseEstimate( + tempMinHour: 8.0, acrophaseHours: 20.0, offsetVsScheduleMinutes: 0, + confidence: .solid, note: "") + XCTAssertEqual(CircadianEngine.chronotype(alignedButLate), .evening) + } + + /// The same table the Kotlin oracle pins, asserted from this side too. `typical` says the model is + /// sane: a 04:30 temperature minimum and an 8 h night put the ideal window at 23:00-07:00. The wrap + /// rows matter because the ideal bedtime routinely lands on the PREVIOUS day. + func testIdealSleepWindowPlacesTheWindowOnTheRing() { + let cases: [(Double, Double, (Double, Double)?)] = [ + (4.5, 8.0, (23.0, 7.0)), (4.5, 5.0, (2.0, 7.0)), (4.5, 10.0, (21.0, 7.0)), + (7.0, 8.0, (1.5, 9.5)), (2.0, 8.0, (20.5, 4.5)), + (1.0, 8.0, (19.5, 3.5)), (23.0, 8.0, (17.5, 1.5)), + (4.5, 0.0, nil), (4.5, -1.0, nil), (4.5, 24.0, nil), + ] + for (tempMin, duration, expected) in cases { + let w = CircadianEngine.idealSleepWindow(tempMinHour: tempMin, durationHours: duration) + if let expected { + XCTAssertEqual(w?.bedHour ?? -1, expected.0, accuracy: 1e-12, "bed for \(tempMin)/\(duration)") + XCTAssertEqual(w?.wakeHour ?? -1, expected.1, accuracy: 1e-12, "wake for \(tempMin)/\(duration)") + } else { + XCTAssertNil(w, "an impossible duration cannot be placed on the ring: \(duration)") + } + } + } + + /// The ideal arc takes the night's OWN length, so the dial compares PHASE alone — which keeps a sleep + /// DEBT from rendering as a body-clock problem. + func testIdealWindowSharesTheWakeAnchorAcrossDurations() { + let short = CircadianEngine.idealSleepWindow(tempMinHour: 4.5, durationHours: 5) + let long = CircadianEngine.idealSleepWindow(tempMinHour: 4.5, durationHours: 10) + XCTAssertEqual(short?.wakeHour, long?.wakeHour) + XCTAssertNotEqual(short?.bedHour, long?.bedHour) + } + + /// The dial's caption quantity. `wrap-late` earns its place: a 23:00 temperature minimum puts the + /// ideal wake at 01:30, so waking at 02:30 is one hour LATE, not twenty-three hours early. + func testSleepWindowOffsetHours() { + let cases: [(Double, Double, Double)] = [ + (4.5, 7.0, 0.0), (4.5, 8.0, 1.0), (4.5, 6.0, -1.0), + (23.0, 2.5, 1.0), (1.0, 23.0, -4.5), (4.5, 19.0, 12.0), + ] + for (tempMin, wake, expected) in cases { + XCTAssertEqual(CircadianEngine.sleepWindowOffsetHours(tempMinHour: tempMin, actualWakeHour: wake), + expected, accuracy: 1e-12, "tempMin \(tempMin) wake \(wake)") + } + } + + /// The dial's caption and the card's existing headline measure DIFFERENT things: a consistent late + /// sleeper is well-aligned with their own schedule while sleeping hours from what their clock wants. + func testWindowOffsetAndScheduleOffsetAreDifferentQuantities() { + let windowOffset = CircadianEngine.sleepWindowOffsetHours(tempMinHour: 8.0, actualWakeHour: 6.5) + XCTAssertEqual(windowOffset, -4.0, accuracy: 1e-12) + let scheduleAligned = CircadianEngine.PhaseEstimate( + tempMinHour: 8.0, acrophaseHours: 20.0, offsetVsScheduleMinutes: 0, + confidence: .solid, note: "") + XCTAssertEqual(scheduleAligned.offsetVsScheduleMinutes, 0) + XCTAssertGreaterThan(abs(windowOffset), 1.0) } } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ClockFormatLocaleTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ClockFormatLocaleTests.swift new file mode 100644 index 0000000000..84f157b066 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ClockFormatLocaleTests.swift @@ -0,0 +1,52 @@ +import XCTest +@testable import StrandAnalytics + +/// #1821: these assert against a REAL `DateFormatter`, not against the identifier string. +/// +/// The hour-cycle locale is the mechanism that carries the setting into every formatter that never says +/// "HH:mm" - the ones using `timeStyle = .short` or a `"jmm"` template. If the identifier syntax were +/// wrong, Foundation would quietly fall back to the base locale and every one of those sites would keep +/// showing the region default while the setting appeared to be applied. That failure is silent, so it has +/// to be caught by exercising Foundation itself. +final class ClockFormatLocaleTests: XCTestCase { + private func shortTime(_ base: String, uses24Hour: Bool) -> String { + let id = ClockFormat.hourCycleLocaleIdentifier(base: base, uses24Hour: uses24Hour) + let f = DateFormatter() + f.locale = Locale(identifier: id) + f.timeZone = TimeZone(identifier: "UTC") + f.timeStyle = .short + f.dateStyle = .none + // 2026-01-02 22:57 UTC — the reported "22:57" / "10:57 PM". + return f.string(from: Date(timeIntervalSince1970: 1_767_394_620)) + } + + func testTimeStyleShortHonoursTheForcedHourCycle() { + // en_GB is a 24-hour region: asking for 12-hour must actually change it. + XCTAssertTrue(shortTime("en_GB", uses24Hour: false).contains("10"), + "12-hour was requested on a 24-hour region and did not take effect") + XCTAssertTrue(shortTime("en_GB", uses24Hour: true).contains("22")) + // en_US is a 12-hour region: the reverse must work too, or the setting is one-directional. + XCTAssertTrue(shortTime("en_US", uses24Hour: true).contains("22"), + "24-hour was requested on a 12-hour region and did not take effect") + XCTAssertTrue(shortTime("en_US", uses24Hour: false).contains("10")) + } + + /// The other half of the surface: templates containing `j`, which resolve the hour from the locale. + func testJTemplateHonoursTheForcedHourCycle() { + func viaTemplate(_ base: String, uses24Hour: Bool) -> String { + let id = ClockFormat.hourCycleLocaleIdentifier(base: base, uses24Hour: uses24Hour) + let f = DateFormatter() + f.locale = Locale(identifier: id) + f.timeZone = TimeZone(identifier: "UTC") + f.setLocalizedDateFormatFromTemplate("jmm") + return f.string(from: Date(timeIntervalSince1970: 1_767_394_620)) + } + XCTAssertTrue(viaTemplate("en_GB", uses24Hour: false).contains("10")) + XCTAssertTrue(viaTemplate("en_US", uses24Hour: true).contains("22")) + } + + func testIdentifierShape() { + XCTAssertEqual(ClockFormat.hourCycleLocaleIdentifier(base: "en_GB", uses24Hour: true), "en_GB@hours=h23") + XCTAssertEqual(ClockFormat.hourCycleLocaleIdentifier(base: "fr_FR", uses24Hour: false), "fr_FR@hours=h12") + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ClockFormatTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ClockFormatTests.swift new file mode 100644 index 0000000000..3388e14882 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ClockFormatTests.swift @@ -0,0 +1,48 @@ +import XCTest +@testable import StrandAnalytics + +final class ClockFormatTests: XCTestCase { + func testSystemDefersToTheDevice() { + XCTAssertTrue(ClockFormat.uses24Hour(preference: .system, systemUses24Hour: true)) + XCTAssertFalse(ClockFormat.uses24Hour(preference: .system, systemUses24Hour: false)) + } + + func testAnExplicitChoiceOverridesTheDevice() { + // The whole point of the setting: a reader in a 24-hour region can ask for 12-hour and get it. + XCTAssertFalse(ClockFormat.uses24Hour(preference: .twelveHour, systemUses24Hour: true)) + XCTAssertTrue(ClockFormat.uses24Hour(preference: .twentyFourHour, systemUses24Hour: false)) + } + + func testUnknownStoredValuesFallBackToSystemNotToAClock() { + XCTAssertEqual(ClockFormatPreference.from(stored: nil), .system) + XCTAssertEqual(ClockFormatPreference.from(stored: ""), .system) + XCTAssertEqual(ClockFormatPreference.from(stored: "24h"), .system, + "an unparseable value must not pin every reader to one clock") + XCTAssertEqual(ClockFormatPreference.from(stored: "twelveHour"), .twelveHour) + XCTAssertEqual(ClockFormatPreference.from(stored: "twentyFourHour"), .twentyFourHour) + } + + /// The stored vocabulary is shared with Android, which writes these exact strings. Pinning the + /// literals here means renaming a case cannot silently make the two platforms disagree about what a + /// user's saved preference means. + func testStoredVocabularyIsPinned() { + XCTAssertEqual(ClockFormatPreference.system.rawValue, "system") + XCTAssertEqual(ClockFormatPreference.twelveHour.rawValue, "twelveHour") + XCTAssertEqual(ClockFormatPreference.twentyFourHour.rawValue, "twentyFourHour") + XCTAssertEqual(ClockFormatPreference.defaultsKey, "clockFormatPreference") + } + + func testHourMinutePattern() { + XCTAssertEqual(ClockFormat.hourMinutePattern(uses24Hour: true), "HH:mm") + XCTAssertEqual(ClockFormat.hourMinutePattern(uses24Hour: false), "h:mm a") + } + + /// The template must never be `j`: that is the locale-resolved hour, and routing the reader's choice + /// back through it is the bug this setting exists to fix. + func testHourMinuteTemplateNeverDefersToTheLocaleHour() { + XCTAssertEqual(ClockFormat.hourMinuteTemplate(uses24Hour: true), "Hmm") + XCTAssertEqual(ClockFormat.hourMinuteTemplate(uses24Hour: false), "hmm") + XCTAssertFalse(ClockFormat.hourMinuteTemplate(uses24Hour: true).contains("j")) + XCTAssertFalse(ClockFormat.hourMinuteTemplate(uses24Hour: false).contains("j")) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CoachSuggestionsTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CoachSuggestionsTests.swift new file mode 100644 index 0000000000..e8b1e7b1db --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CoachSuggestionsTests.swift @@ -0,0 +1,156 @@ +import Foundation +import XCTest +@testable import StrandAnalytics +import WhoopStore + +/// Byte-identity pin for the contextual Coach suggestion chips. The same inputs MUST produce the +/// same chip strings as the Android twin `com.noop.analytics.CoachSuggestionsTest`. Cross-platform +/// parity is the contract; if you change a chip string here, change it there in the same PR. +final class CoachSuggestionsTests: XCTestCase { + + private let fallback = CoachSuggestions.fallback + + /// Build a DailyMetric with only the fields the chip logic reads, everything else nil. + private func metric( + day: Int, recovery: Double? = nil, hrv: Double? = nil, + sleepMin: Double? = nil, strain: Double? = nil + ) -> DailyMetric { + DailyMetric( + day: String(format: "2026-01-%02d", day), + totalSleepMin: sleepMin, efficiency: nil, deepMin: nil, remMin: nil, lightMin: nil, + disturbances: nil, restingHr: nil, avgHrv: hrv, recovery: recovery, strain: strain, + exerciseCount: nil, spo2Pct: nil, skinTempDevC: nil, respRateBpm: nil + ) + } + + // MARK: - No-data fallback + + func testNilTodayReturnsFallback() { + XCTAssertEqual(CoachSuggestions.suggestions(for: nil, recent: []), fallback) + } + + func testAllNilFieldsReturnsFallback() { + let today = metric(day: 10) // all four signals nil + XCTAssertEqual(CoachSuggestions.suggestions(for: today, recent: []), fallback) + } + + // MARK: - Charge bands + + func testLowChargeActiveRecoveryChip() { + let today = metric(day: 10, recovery: 20) + let chips = CoachSuggestions.suggestions(for: today, recent: []) + XCTAssertEqual(chips.first, "Active recovery only today — what should I do?") + XCTAssertEqual(chips.last, "Analyse my sleep") + XCTAssertTrue(chips.count >= 2 && chips.count <= 4) + } + + func testMidChargeQualityOverVolumeChip() { + let today = metric(day: 10, recovery: 50) + let chips = CoachSuggestions.suggestions(for: today, recent: []) + XCTAssertEqual(chips.first, "Quality over volume today — plan my session") + } + + func testHighChargeGreenLightChip() { + let today = metric(day: 10, recovery: 90) + let chips = CoachSuggestions.suggestions(for: today, recent: []) + XCTAssertEqual(chips.first, "Green light — how hard can I push today?") + } + + func testChargeBoundary34IsMidBand() { + let today = metric(day: 10, recovery: 34) + XCTAssertEqual(CoachSuggestions.suggestions(for: today, recent: []).first, + "Quality over volume today — plan my session") + } + + func testChargeBoundary67IsGreenBand() { + let today = metric(day: 10, recovery: 67) + XCTAssertEqual(CoachSuggestions.suggestions(for: today, recent: []).first, + "Green light — how hard can I push today?") + } + + // MARK: - HRV trending down + + func testHrvBelowBaselineAddsDownChip() { + // 30 days of HRV ~60, then today at 45 (< 0.85 * 60 = 51). + let recent = (1...30).map { metric(day: $0, hrv: 60) } + let today = metric(day: 31, recovery: 70, hrv: 45) + let chips = CoachSuggestions.suggestions(for: today, recent: recent) + XCTAssertTrue(chips.contains("Why is my HRV trending down?")) + } + + func testHrvAtBaselineDoesNotAddDownChip() { + let recent = (1...30).map { metric(day: $0, hrv: 60) } + let today = metric(day: 31, recovery: 70, hrv: 60) + let chips = CoachSuggestions.suggestions(for: today, recent: recent) + XCTAssertFalse(chips.contains("Why is my HRV trending down?")) + } + + func testHrvBaselineExcludesToday() { + // Today is in `recent` too; it must be excluded from the baseline so a single low day can + // still register as "down" against the prior baseline. + var recent = (1...30).map { metric(day: $0, hrv: 60) } + let today = metric(day: 31, recovery: 70, hrv: 45) + recent.append(today) + let chips = CoachSuggestions.suggestions(for: today, recent: recent) + XCTAssertTrue(chips.contains("Why is my HRV trending down?")) + } + + // MARK: - Poor sleep + + func testPoorSleepAddsRecoverChip() { + let today = metric(day: 10, recovery: 70, sleepMin: 300) // 5h + let chips = CoachSuggestions.suggestions(for: today, recent: []) + XCTAssertTrue(chips.contains("I slept poorly — how do I recover today?")) + } + + func testSixHoursIsNotPoor() { + let today = metric(day: 10, recovery: 70, sleepMin: 360) // exactly 6h + let chips = CoachSuggestions.suggestions(for: today, recent: []) + XCTAssertFalse(chips.contains("I slept poorly — how do I recover today?")) + } + + // MARK: - High strain + + func testHighStrainAddsLoadedChip() { + let today = metric(day: 10, recovery: 70, strain: 16) + let chips = CoachSuggestions.suggestions(for: today, recent: []) + XCTAssertTrue(chips.contains("Have I done enough today, or push more?")) + } + + func testStrainBelow14DoesNotAddLoadedChip() { + let today = metric(day: 10, recovery: 70, strain: 13) + let chips = CoachSuggestions.suggestions(for: today, recent: []) + XCTAssertFalse(chips.contains("Have I done enough today, or push more?")) + } + + // MARK: - Cap + stable generic + + func testAllSignalsFireCapsAtFour() { + let recent = (1...30).map { metric(day: $0, hrv: 60) } + let today = metric(day: 31, recovery: 20, hrv: 40, sleepMin: 300, strain: 16) + let chips = CoachSuggestions.suggestions(for: today, recent: recent) + // charge + hrv + sleep + strain + stable generic = 5 candidates → capped at 4. + XCTAssertEqual(chips.count, 4) + // The stable generic is appended last; with the cap at 4, it is the 4th element (the 5th + // candidate, the strain chip, is dropped). Charge is first. + XCTAssertEqual(chips.first, "Active recovery only today — what should I do?") + XCTAssertEqual(chips.last, "Analyse my sleep") + } + + func testChargeOnlyYieldsChargeAndGeneric() { + let today = metric(day: 10, recovery: 90) + let chips = CoachSuggestions.suggestions(for: today, recent: []) + XCTAssertEqual(chips, ["Green light — how hard can I push today?", "Analyse my sleep"]) + } + + // MARK: - Byte-identity of the fallback list + + func testFallbackListIsCanonical() { + XCTAssertEqual(fallback, [ + "How's my recovery trending this week?", + "What should today's training look like?", + "Analyse my sleep", + "Why am I run down?", + ]) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ConnectionReadoutTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ConnectionReadoutTests.swift index fac6073f3a..27eb5df6fb 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ConnectionReadoutTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ConnectionReadoutTests.swift @@ -204,9 +204,11 @@ final class ConnectionReadoutTests: XCTestCase { XCTAssertEqual( ConnectionReadout.clockLatchedLabel(deviceClockUnix: nil, strapNewestUnix: 1_782_475_600), "yes") + // #1823: no clock was READ on this path - it is the 5/MG fallback, where the only evidence is + // how the strap dated its records. The wording must not claim a clock reading. XCTAssertEqual( ConnectionReadout.clockLatchedLabel(deviceClockUnix: nil, strapNewestUnix: 40_000_000), - "no (RTC reads 1970/71)") + "no (records dated 1970/71)") XCTAssertEqual( ConnectionReadout.clockLatchedLabel(deviceClockUnix: nil, strapNewestUnix: nil), "no (waiting for the strap clock)") @@ -224,6 +226,75 @@ final class ConnectionReadoutTests: XCTestCase { "no signal seen yet must not fabricate a fault") } + /// #1818: the remedy must track the battery. A charged strap told to "charge to 100%" is the bug + /// the field report hit - the user had already done it, twice. + func testRtcWarningRemedyTracksBattery() { + let flat = ConnectionReadout.rtcWarning(deviceClockUnix: 40_000_000, strapNewestUnix: nil, + batteryPct: 40) + XCTAssertEqual(flat?.contains("Charge the strap to 100%"), true, + "a low strap keeps the charge advice - a flat battery really does reset the RTC") + + let charged = ConnectionReadout.rtcWarning(deviceClockUnix: 40_000_000, strapNewestUnix: nil, + batteryPct: 100) + XCTAssertEqual(charged?.contains("Charge the strap to 100%"), false, + "an already-charged strap must not be sent round the loop it just ran") + XCTAssertEqual(charged?.contains("already charged"), true) + XCTAssertEqual(charged?.contains("strap log"), true, "it must name the next actionable step") + // The charged copy must stay true for EVERY strap. "NOOP re-sends the clock on every connect" + // holds on WHOOP4 but not on a 5/MG, where the write is gated behind didBond and an unbondable + // strap (#1635) is never clocked at all - the strap most likely to be showing this warning. + XCTAssertEqual(charged?.contains("every connect"), false, + "no model-specific mechanism claim in copy shown to every model") + + // Pin the VALUE, not just the symbol: feeding the constant back into the function under test + // can never catch a wrong threshold, and nothing else would catch it drifting away from the + // Kotlin twin - the two platforms would each keep passing while giving different advice. + XCTAssertEqual(ConnectionReadout.rtcAlreadyChargedPct, 95) + + // Boundary, from both sides, with literals: inclusive at 95, charge advice at 94. + let atThreshold = ConnectionReadout.rtcWarning(deviceClockUnix: 40_000_000, + strapNewestUnix: nil, batteryPct: 95) + XCTAssertEqual(atThreshold?.contains("already charged"), true) + let justBelow = ConnectionReadout.rtcWarning(deviceClockUnix: 40_000_000, + strapNewestUnix: nil, batteryPct: 94) + XCTAssertEqual(justBelow?.contains("Charge the strap to 100%"), true) + + // Battery not read yet: we only withdraw advice on evidence, so the default stands. + let unknown = ConnectionReadout.rtcWarning(deviceClockUnix: 40_000_000, strapNewestUnix: nil) + XCTAssertEqual(unknown?.contains("Charge the strap to 100%"), true) + + // A sane clock stays silent no matter how full the battery is. + XCTAssertNil(ConnectionReadout.rtcWarning(deviceClockUnix: 1_782_475_600, + strapNewestUnix: 1_782_475_000, batteryPct: 100)) + } + + /// #1809: the epitaph exists so a strap log can STATE that nothing arrived, rather than a reporter + /// inferring it from the fact that every logged line happened to be outgoing. + func testLinkEpitaph() { + let silent = ConnectionReadout.linkEpitaph(upMillis: 4_123, inboundFrames: 0, inboundBytes: 0, + cmdChannelFrames: 0, realtimeArmed: false, + ended: "CBError.connectionTimeout(6)") + XCTAssertEqual(silent, + "Link epitaph: up 4123ms, inbound 0 frames / 0 bytes (cmd-channel 0), " + + "realtime armed=no, ended=CBError.connectionTimeout(6)" + + " - the strap sent NOTHING on this link") + + // A link that carried traffic must NOT claim silence. + let alive = ConnectionReadout.linkEpitaph(upMillis: 61_000, inboundFrames: 812, + inboundBytes: 40_990, cmdChannelFrames: 9, + realtimeArmed: true, ended: "intentional") + XCTAssertEqual(alive, + "Link epitaph: up 61000ms, inbound 812 frames / 40990 bytes (cmd-channel 9), " + + "realtime armed=yes, ended=intentional") + XCTAssertFalse(alive.contains("NOTHING")) + + // Negatives are clamped rather than printed: a monotonic-clock hiccup must not emit "up -3ms". + XCTAssertTrue(ConnectionReadout.linkEpitaph(upMillis: -3, inboundFrames: -1, inboundBytes: -9, + cmdChannelFrames: -2, realtimeArmed: false, + ended: "x") + .hasPrefix("Link epitaph: up 0ms, inbound 0 frames / 0 bytes (cmd-channel 0)")) + } + func testLastFrameLabel() { XCTAssertEqual(ConnectionReadout.lastFrameLabel(lastFrameUnix: 990, nowUnix: 1_002), "12s ago") XCTAssertEqual(ConnectionReadout.lastFrameLabel(lastFrameUnix: nil, nowUnix: 1_002), "no frames yet") diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DayCaloriesTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DayCaloriesTests.swift index 05a0691d3d..3fa2d5e5c5 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DayCaloriesTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DayCaloriesTests.swift @@ -8,8 +8,8 @@ import WhoopProtocol /// DayCaloriesTest vectors value-for-value. final class DayCaloriesTests: XCTestCase { - private func hrDay(bpm: Int, n: Int) -> [HRSample] { - (0.. [HRSample] { + (0..=` quietly weakened to `>` + /// would have moved that boundary a full day and nothing would have failed. + func testFallbackTakesTheMidnightExactlyEighteenHoursAfterOnset() { + // 06:00 + 18 h lands exactly on the next midnight: taken directly, not rolled past. + XCTAssertEqual(DayCycleResolver.fallbackMidnight(after: 6 * 3_600, offsetSec: 0), 86_400) + } + + /// The rolling branch at a different onset from the case above, so the two are not the same test twice. + func testFallbackRollsWhenTheNextMidnightIsTooSoon() { + // 23:00 + 18 h overshoots the next midnight, so the one after it wins. + XCTAssertEqual(DayCycleResolver.fallbackMidnight(after: 23 * 3_600, offsetSec: 0), 2 * 86_400) + } + + /// The boundary is LOCAL midnight, not UTC midnight — and until now nothing said so. Every + /// day-cycle case on both platforms passed a zero offset, so the whole offset arithmetic, which is + /// the part that decides which local day a boundary lands on, was unpinned. This repo has already + /// had days re-bucket on travel once, so it is worth a case rather than an argument. + /// + /// 06:00 local at UTC-5 is 11:00 UTC. Plus 18 h is 05:00 UTC the next day, which IS local midnight + /// there, so the direct branch takes it: 104_400 = 29 h UTC = 00:00 local. A resolver that floored + /// to UTC midnight would answer 86_400 and be a day out for a third of the planet. + func testFallbackLandsOnLocalMidnightNotUTC() { + let offsetSec = -5 * 3_600 + let onsetLocal0600 = 6 * 3_600 - offsetSec + XCTAssertEqual(DayCycleResolver.fallbackMidnight(after: onsetLocal0600, offsetSec: offsetSec), + 104_400) + } + + func testAbsoluteCapStillUsesSyntheticMidnight() { + let sleep = DayCycleWindow(id: "sleep", startInclusive: 0, endExclusive: 0, + displayDay: "1970-01-01", source: .detectedSleep) + XCTAssertEqual(DayCycleResolver.activeWindow(mode: .sleepOnset, latestSleep: sleep, + now: 40 * 3_600, offsetSec: 0).source, + .syntheticMidnight) + } + + func testCoverageSegmentsPreferPriorityWithoutCrossingDeviceCounters() { + let window = PhysiologicalSteps.CycleWindow(sleepId: "night", onset: 100, endExclusive: 500) + let segments = PhysiologicalSteps.ownerSegmentsFromCoverage(window, coverage: [ + .init(owner: "secondary", onset: 100, endExclusive: 350, priority: 1), + .init(owner: "active", onset: 200, endExclusive: 500, priority: 0), + ], fallbackOwner: "secondary") + XCTAssertEqual(segments, [ + .init(owner: "secondary", onset: 100, endExclusive: 200), + .init(owner: "active", onset: 200, endExclusive: 500), + ]) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DetectionFunnelTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DetectionFunnelTests.swift index 42db1a7f80..de3e839b6e 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DetectionFunnelTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DetectionFunnelTests.swift @@ -141,7 +141,11 @@ final class DetectionFunnelTests: XCTestCase { f.droppedShort = 4; f.droppedNoHR = 0; f.droppedLowIntensity = 0; f.kept = 0 XCTAssertEqual( WorkoutDetector.detectionFunnelLine(day: "2026-08-24", funnel: f), - "effort detect day=2026-08-24 hr=34137 motion=34136 restHR=59 floor=74 " + // Renamed from "effort detect": this funnel is WORKOUT-BOUT detection, and sitting under + // the Effort name made it read as the explanation for the Today ring's number. It is not — + // StrainScorer.strain computes that from HR alone, with no motion input — and the confusion + // has already produced one wrong diagnosis from a real log. `effort score` is the ring's line. + "workout detect day=2026-08-24 hr=34137 motion=34136 restHR=59 floor=74 " + "motionOK=1203 hrMissing=12 hrTooLow=1103 active=88 runs=6 bridged=4 " + "longestRunS=212 meanRunS=74 " + "short=4 noHR=0 lowIntensity=0 kept=0") diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/EffectRankerTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/EffectRankerTests.swift index c3c3d63ce0..7c704ae5d5 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/EffectRankerTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/EffectRankerTests.swift @@ -38,6 +38,7 @@ final class EffectRankerTests: XCTestCase { } let out = EffectRanker.rank(behaviors: ["Alcohol": behaviorDays], + controls: ["Alcohol": Set(outcome.keys).subtracting(behaviorDays)], outcomeByDay: outcome, outcome: "Charge") let r = row(out, "Alcohol") XCTAssertNotNil(r) @@ -62,7 +63,7 @@ final class EffectRankerTests: XCTestCase { private func effectAtLag(_ behaviorDays: Set, _ outcome: [String: Double], _ lag: Int) -> BehaviorEffect? { let shifted = EffectRanker.shiftedOutcome(outcome, byLag: lag) - return BehaviorInsights.effect(behaviorDays: behaviorDays, outcomeByDay: shifted, + return BehaviorInsights.effect(behaviorDays: behaviorDays, controlDays: Set(shifted.keys).subtracting(behaviorDays), outcomeByDay: shifted, behavior: "Alcohol", outcome: "Charge") } @@ -82,6 +83,7 @@ final class EffectRankerTests: XCTestCase { for d in 1...8 { outcome[ymd(2026, 7, d)] = 70 + jitter(d) } // plenty of "without" let out = EffectRanker.rank(behaviors: ["Sparse": thin], + controls: ["Sparse": Set(outcome.keys).subtracting(thin)], outcomeByDay: outcome, outcome: "Charge") XCTAssertTrue(out.isEmpty) } @@ -110,6 +112,8 @@ final class EffectRankerTests: XCTestCase { for d in 10...20 { outcome[ymd(2026, 5, d)] = 70 + jitter(d) } let out = EffectRanker.rank(behaviors: ["Big": big, "Small": small], + controls: ["Big": Set(outcome.keys).subtracting(big), + "Small": Set(outcome.keys).subtracting(small)], outcomeByDay: outcome, outcome: "Charge") XCTAssertEqual(out.map { $0.behavior }, ["Big", "Small"]) // |d| Big > Small XCTAssertEqual(row(out, "Big")!.lag, 0) // both are same-day effects diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/EffectiveEffortTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/EffectiveEffortTests.swift index 01116e9626..3981684681 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/EffectiveEffortTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/EffectiveEffortTests.swift @@ -47,4 +47,61 @@ final class EffectiveEffortTests: XCTestCase { func testEqualSourcesResolveToThatValue() { XCTAssertEqual(StrainScorer.effectiveEffort(live: 7.25, stored: 7.25)!, 7.25, accuracy: 1e-9) } + + /// #37: when both sources are zero, every sign pairing has the canonical positive-zero bits. + func testBothPresentZerosCanonicalizePositiveZero() { + let cases: [(label: String, live: Double, stored: Double)] = [ + ("positive/positive", 0.0, 0.0), + ("positive/negative", 0.0, -0.0), + ("negative/positive", -0.0, 0.0), + ("negative/negative", -0.0, -0.0), + ] + + for testCase in cases { + let result = StrainScorer.effectiveEffort(live: testCase.live, stored: testCase.stored)! + XCTAssertEqual(result.bitPattern, 0.0.bitPattern, testCase.label) + } + } + + /// A missing source is passthrough, including its exact signed-zero and NaN representation. + func testSingleSourcePassesThroughBitForBit() { + let values = [ + 0.0, + -0.0, + 7.25, + -7.25, + Double.infinity, + -Double.infinity, + Double(bitPattern: 0x7ff8_0000_0000_0042), + ] + + for value in values { + XCTAssertEqual(StrainScorer.effectiveEffort(live: value, stored: nil)!.bitPattern, + value.bitPattern) + XCTAssertEqual(StrainScorer.effectiveEffort(live: nil, stored: value)!.bitPattern, + value.bitPattern) + } + } + + /// The zero canonicalization must not broaden into a replacement for Swift's MAX semantics. + func testBothPresentNonzeroAndNaNBehaviorIsUnchanged() { + let nanA = Double(bitPattern: 0x7ff8_0000_0000_0042) + let nanB = Double(bitPattern: 0x7ff8_0000_0000_0024) + let cases: [(live: Double, stored: Double, expected: Double)] = [ + (2.3, 0.5, 2.3), + (7.25, 7.25, 7.25), + (Double.infinity, 12.0, Double.infinity), + (12.0, Double.infinity, Double.infinity), + (-Double.infinity, -Double.infinity, -Double.infinity), + (nanA, 1.0, nanA), + (1.0, nanA, 1.0), + (nanA, nanB, nanA), + (nanB, nanA, nanB), + ] + + for testCase in cases { + let result = StrainScorer.effectiveEffort(live: testCase.live, stored: testCase.stored)! + XCTAssertEqual(result.bitPattern, testCase.expected.bitPattern) + } + } } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/EffortScoreFunnelTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/EffortScoreFunnelTests.swift new file mode 100644 index 0000000000..e40e20bb5a --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/EffortScoreFunnelTests.swift @@ -0,0 +1,44 @@ +import XCTest +import WhoopProtocol +@testable import StrandAnalytics + +/// The Effort score's funnel line — the trace `StrainScorer` had none of. +/// +/// Twin of the Kotlin `EffortScoreFunnelTest`. The expected strings are the Kotlin ones: these two lines +/// exist so an Android and an Apple log of the same day compare directly, so asserting each side against +/// itself would prove only that each is self-consistent. +final class EffortScoreFunnelTests: XCTestCase { + + func testACalmDayReportsTheZeroItMeasured() { + XCTAssertEqual( + StrainScorer.scoreFunnelLine(day: "2026-08-31", hrSamples: 39339, enough: true, + maxHR: 185.0, maxHRProvided: true, restingHR: 58.0, + method: .edwards, trimp: 0.0, strain: 0.0), + "effort score day=2026-08-31 hr=39339 enough=true hrMax=185.0(provided) rhr=58.0" + + " reserve=127.0 method=edwards trimp=0.0 strain=0.0" + ) + } + + /// The distinction the ring cannot show. A refusal and a genuine zero both render as "0"; only the + /// line says which happened, and n/a is what makes the refusal legible. + func testARefusalIsNotAZero() { + XCTAssertEqual( + StrainScorer.scoreFunnelLine(day: "2026-08-31", hrSamples: 12, enough: false, + maxHR: 185.0, maxHRProvided: false, restingHR: 58.0, + method: .edwards, trimp: nil, strain: nil), + "effort score day=2026-08-31 hr=12 enough=false hrMax=185.0(default) rhr=58.0" + + " reserve=127.0 method=edwards trimp=n/a strain=n/a" + ) + } + + /// The hook must cost nothing when nobody is watching, and must not fire at view refresh rate: unlike + /// the Kotlin twin this scorer is memoized because the Today view re-reads it on every live-HR tick. + func testNoDiagSinkMeansNoLine() { + let hr = (0..<5).map { HRSample(ts: 1_700_000_000 + $0 * 60, bpm: 60) } + var emitted: [String] = [] + XCTAssertNil(StrainScorer.strain(hr)) + XCTAssertNil(StrainScorer.strain(hr, diag: { emitted.append($0) }, day: "2026-08-31")) + XCTAssertEqual(emitted.count, 1) + XCTAssertTrue(emitted[0].hasPrefix("effort score day=2026-08-31 "), emitted[0]) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRVAnalyzerSampleOrdTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRVAnalyzerSampleOrdTests.swift index c17a9cb062..45e1ff6361 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRVAnalyzerSampleOrdTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRVAnalyzerSampleOrdTests.swift @@ -82,4 +82,27 @@ final class HRVAnalyzerSampleOrdTests: XCTestCase { XCTAssertEqual(HRVAnalyzer.pct(3, 8), 38) // 37.5 -> 38 XCTAssertEqual(HRVAnalyzer.pct(0, 0), 0) } + + /// Twin of Kotlin `pctSurvivesAWholeNightOfBeatTime`. `deliveryHistogram` passes a whole night's + /// beat-time in milliseconds, so `part * 200` needs more than 32 bits. `Int` is 64-bit here, so this + /// passed before the widening as well - it is a parity pin and a guard for `arm64_32`, where + /// StrandAnalytics also builds and where the Kotlin failure would reproduce exactly. + func testPctSurvivesAWholeNightOfBeatTime() { + XCTAssertEqual(HRVAnalyzer.pct(15_264_177, 35_498_088), 43) + XCTAssertEqual(HRVAnalyzer.pct(36_296_594, 39_886_368), 91) + } + + /// Twin of Kotlin `pctWrapWasNotAlwaysNegative`. The 32-bit wrap did not always produce a NEGATIVE + /// percentage, so "is it negative" was never a sound test for an affected capture. + /// + /// These pin the exact boundary and the readable-wrong case. The bound is `part * 200 + total` + /// exceeding `Int32.max`, not `part * 200` alone: 10,683,998 was still computed correctly in 32 + /// bits, 10,683,999 wrapped to -100, and a 100% multi-share 8 h night wrapped to 25 - positive, + /// plausible, and wrong by 75 points. `Int` is 64-bit here, so these pass either way; they are a + /// parity pin and a guard for `arm64_32`, where the Kotlin failure would reproduce exactly. + func testPctWrapWasNotAlwaysNegative() { + XCTAssertEqual(HRVAnalyzer.pct(10_683_998, 10_683_998), 100) + XCTAssertEqual(HRVAnalyzer.pct(10_683_999, 10_683_999), 100) + XCTAssertEqual(HRVAnalyzer.pct(28_800_000, 28_800_000), 100) + } } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HeartRateRecoveryTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HeartRateRecoveryTests.swift index e246db99a9..8a80ed8efe 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HeartRateRecoveryTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HeartRateRecoveryTests.swift @@ -15,6 +15,15 @@ final class HeartRateRecoveryTests: XCTestCase { return values.enumerated().map { i, bpm in HRSample(ts: target - values.count / 2 + i, bpm: bpm) } } + private func result(for eligibilitySamples: [HRSample]) -> HeartRateRecovery.Result? { + HeartRateRecovery.calculate( + samples: eligibilitySamples + window(minutes: 1, values: [140, 140, 140]), + workoutStart: end - 300, + workoutEnd: end, + maxHR: 200 + ) + } + func testCalculatesOneTwoAndFiveMinuteDropsFromRobustReadings() { let samples = denseEligible() + window(minutes: 1, values: [146, 146, 220, 146, 146]) // optical spike cannot own the reading @@ -42,6 +51,58 @@ final class HeartRateRecoveryTests: XCTestCase { workoutEnd: end, maxHR: 200)) } + func testRejectsThreeSeparatedFortySecondHighIntensityRuns() { + let samples = [ + HRSample(ts: end - 140, bpm: 170), HRSample(ts: end - 130, bpm: 170), + HRSample(ts: end - 120, bpm: 170), HRSample(ts: end - 110, bpm: 170), + HRSample(ts: end - 100, bpm: 120), + HRSample(ts: end - 90, bpm: 170), HRSample(ts: end - 80, bpm: 170), + HRSample(ts: end - 70, bpm: 170), HRSample(ts: end - 60, bpm: 170), + HRSample(ts: end - 50, bpm: 120), + HRSample(ts: end - 40, bpm: 170), HRSample(ts: end - 30, bpm: 170), + HRSample(ts: end - 20, bpm: 170), HRSample(ts: end - 10, bpm: 170), + HRSample(ts: end, bpm: 120), + ] + + XCTAssertNil(result(for: samples)) + } + + func testGapExactlyAtCapUsesLeftSampleAndExactMinimumIsAccepted() { + let samples = stride(from: end - 120, through: end - 10, by: 10) + .map { HRSample(ts: $0, bpm: 170) } + + [HRSample(ts: end, bpm: 120)] + + XCTAssertEqual(result(for: samples), .init(endHR: 170, after1Minute: 30, + after2Minutes: nil, after5Minutes: nil)) + } + + func testGapOverCapResetsTheQualifyingRun() { + let beforeGap = stride(from: end - 180, through: end - 110, by: 10) + .map { HRSample(ts: $0, bpm: 170) } + let afterGap = stride(from: end - 99, through: end - 9, by: 10) + .map { HRSample(ts: $0, bpm: 170) } + + [HRSample(ts: end, bpm: 170)] + + XCTAssertNil(result(for: beforeGap + afterGap)) + } + + func testBelowThresholdIntervalResetsTheQualifyingRun() { + let samples = stride(from: end - 130, through: end, by: 10).map { ts in + HRSample(ts: ts, bpm: ts == end - 60 ? 120 : 170) + } + + XCTAssertNil(result(for: samples)) + } + + func testDuplicateTimestampDoesNotBreakAnOtherwiseContinuousRun() { + let samples = stride(from: end - 120, through: end, by: 10) + .map { HRSample(ts: $0, bpm: 170) } + + [HRSample(ts: end - 60, bpm: 120)] + + XCTAssertEqual(result(for: samples), .init(endHR: 170, after1Minute: 30, + after2Minutes: nil, after5Minutes: nil)) + } + func testDoesNotCreditPreWorkoutHeartRateTowardEligibility() { let samples = denseEligible() + window(minutes: 1, values: [140, 140, 140]) // Only the final minute belongs to this workout. The preceding high HR must not make it eligible. diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HrOnlyPhysiologyIsolationTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HrOnlyPhysiologyIsolationTests.swift new file mode 100644 index 0000000000..fd0178b99e --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HrOnlyPhysiologyIsolationTests.swift @@ -0,0 +1,90 @@ +import Foundation +import XCTest +@testable import StrandAnalytics + +/// How an HR-only night reaches the day's physiology, guarded at the level it actually fails. +/// +/// The twin of the Kotlin `HrOnlyPhysiologyIsolationTest`, which had no Apple counterpart until #1884 — +/// so the decision was pinned on one platform and free to drift on the other, which is precisely the +/// asymmetry the parity contract exists to prevent. +/// +/// #1801 made this an EXCLUSION: an HR-only night was kept out of every physiological aggregate. #1884 +/// made it a PREFERENCE, because the exclusion was discarding a computed HRV and leaving Charge with no +/// input at all. The night is still marked `hrOnly` for anything that wants to weigh it down; what it no +/// longer gets is a silent delete. +/// +/// A nil `restingHR`/`avgHRV` only ever protected an aggregate that READS those fields. Two of the day's +/// physiological aggregates do not: the deep-window HRV pool and the SDNN index both re-derive from `rr` +/// over each session's own stages, so an HR-only night — which has stages — would be folded into both, +/// and from there into Charge and the HRV baseline. `deepHrvWindow` is a user setting, so that path is +/// reachable, not theoretical. +/// +/// Read from the source because `physiologySessions` is a local inside `analyzeDay` and cannot be +/// observed directly, and because the failure this guards is a FUTURE aggregate reaching for `matched` +/// out of habit. A behavioural test on one path would not catch that; this does. +final class HrOnlyPhysiologyIsolationTests: XCTestCase { + + /// Walk up from this test file to the package root, so the lookup survives whatever directory the + /// test runner happens to start in. + private func engineSource() throws -> String { + var dir = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + for _ in 0..<5 { + let f = dir.appendingPathComponent("Sources/StrandAnalytics/AnalyticsEngine.swift") + if FileManager.default.fileExists(atPath: f.path) { + return try String(contentsOf: f, encoding: .utf8) + } + dir = dir.deletingLastPathComponent() + } + XCTFail("AnalyticsEngine.swift not found — this test must not pass by default") + throw CocoaError(.fileNoSuchFile) + } + + /// The slice of `analyzeDay` running from just after the physiology set is named through the end of + /// the SDNN call, which is the last of the four aggregates built from it. + private func analyzeDayBody() throws -> String { + let src = try engineSource() + let decl = try XCTUnwrap(src.range(of: "let physiologySessions"), + "analyzeDay must name the physiology-session set") + // Start AFTER the declaration: those lines read `matched` legitimately, since defining the + // preferred set is the one place the unfiltered one belongs. + let from = try XCTUnwrap(src.range(of: "\n", range: decl.upperBound.. Bool { + !SleepStager.sessionHrvWindows(start: start, end: end, rr: rr, stages: []).compactMap { $0.rmssd }.isEmpty + } + + /// Two near-equal beats per second: the shape a same-second collapse WOULD bring back under the + /// ceiling. It must still be withheld, and this is the case that guards the gate's cheap + /// classification — `sessionAvgHRV` passes coverage as its own collapsed figure to avoid a sort, so a + /// night of this shape reports `crossSecondOverCount` internally. The outcome is what matters and is + /// asserted here; restoring the real collapsed figure would relabel this night and change nothing. + func testSameSecondOverCountIsWithheld() { + let start = 1_000, end = 1_600 + var rr: [RRInterval] = [] + for i in 0..<600 { + rr.append(RRInterval(ts: start + i, rrMs: 900)) + rr.append(RRInterval(ts: start + i, rrMs: 905)) + } + XCTAssertTrue(windowsYieldRMSSD(start, end, rr), "precondition: these beats DO yield an RMSSD") + XCTAssertNil(SleepStager.sessionAvgHRV(start: start, end: end, rr: rr), + "an over-counted night must report no HRV") + } + + /// Two beats per second far enough apart that a same-second collapse cannot reach them. + func testCrossSecondOverCountIsWithheld() { + let start = 1_000, end = 1_600 + var rr: [RRInterval] = [] + for i in 0..<600 { + rr.append(RRInterval(ts: start + i, rrMs: 880)) + rr.append(RRInterval(ts: start + i, rrMs: 960)) + } + XCTAssertTrue(windowsYieldRMSSD(start, end, rr), "precondition: these beats DO yield an RMSSD") + XCTAssertNil(SleepStager.sessionAvgHRV(start: start, end: end, rr: rr), + "an over-counted night must report no HRV") + } + + /// The gate must not touch an ordinary night: one beat per second, coverage ~1.0. + func testAPlausibleNightStillReportsItsHRV() { + let start = 1_000, end = 1_600 + let rr = (0..<600).map { RRInterval(ts: start + $0, rrMs: $0 % 2 == 0 ? 980 : 1_020) } + let hrv = SleepStager.sessionAvgHRV(start: start, end: end, rr: rr) + XCTAssertNotNil(hrv, "a plausible night must keep its HRV") + XCTAssertGreaterThan(hrv ?? 0, 0, "and it must be a real reading, not zero") + } + + /// A sparse night is underCovered, which is honest data and stays trusted. + func testAnUnderCoveredNightIsNotGated() { + let start = 1_000, end = 1_600 + let rr = (0..<300).map { RRInterval(ts: start + $0 * 2, rrMs: $0 % 2 == 0 ? 980 : 1_020) } + XCTAssertNotNil(SleepStager.sessionAvgHRV(start: start, end: end, rr: rr), + "sparse is not the same as over-counted") + } + + /// The verdict mapping itself, so the seam above and the rule stay pinned separately. + func testOnlyTheTwoOverCountVerdictsRefuse() { + XCTAssertFalse(HRVAnalyzer.successiveDiffIsTrustworthy(.sameSecondOverCount)) + XCTAssertFalse(HRVAnalyzer.successiveDiffIsTrustworthy(.crossSecondOverCount)) + XCTAssertTrue(HRVAnalyzer.successiveDiffIsTrustworthy(.plausible)) + XCTAssertTrue(HRVAnalyzer.successiveDiffIsTrustworthy(.underCovered)) + XCTAssertTrue(HRVAnalyzer.successiveDiffIsTrustworthy(.unmeasurable)) + } + + /// It refuses exactly what the SDNN gate refuses: same rule, stated twice on purpose, never drifting. + func testItRefusesTheSameVerdictsAsTheSdnnGate() { + let all: [HRVAnalyzer.RrCoverageVerdict] = + [.plausible, .underCovered, .sameSecondOverCount, .crossSecondOverCount, .unmeasurable] + for v in all { + XCTAssertEqual(HRVAnalyzer.beatSpreadIsTrustworthy(v), + HRVAnalyzer.successiveDiffIsTrustworthy(v), + "verdict \(v) must be judged alike by both gates") + } + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LinkBankedSummaryTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LinkBankedSummaryTests.swift new file mode 100644 index 0000000000..7a806e850d --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LinkBankedSummaryTests.swift @@ -0,0 +1,61 @@ +import XCTest +@testable import StrandAnalytics + +/// #1635: the per-link banked line, split by PATH. Byte-identical twin of the Kotlin +/// `LinkBankedSummaryTest`, including the exact sentences. +/// +/// The split is by path rather than by stream because the realtime decoder yields only +/// hr/rr/events/battery — gravity, respiratory, skin temperature, SpO2 and steps arrive solely through +/// the offload. An earlier live-only version named those five as empty on EVERY link, bonded or not. +final class LinkBankedSummaryTests: XCTestCase { + + private func line(liveHr: Int = 0, liveRr: Int = 0, chunks: Int = 0, oHr: Int = 0, oRr: Int = 0, + oGrav: Int = 0, oResp: Int = 0, oSkin: Int = 0, oSpo2: Int = 0, + oSteps: Int? = 0) -> String { + ConnectionReadout.linkBankedSummary( + liveHr: liveHr, liveRr: liveRr, offloadChunks: chunks, + offloadHr: oHr, offloadRr: oRr, offloadGravity: oGrav, + offloadResp: oResp, offloadSkinTemp: oSkin, offloadSpo2: oSpo2, offloadSteps: oSteps) + } + + func testAnUnbondedStrapReadsLiveTrafficWithAnOffloadThatNeverRan() { + XCTAssertEqual( + line(liveHr: 12, liveRr: 7, chunks: 0), + "banked this link: live hr=12 rr=7 | offload none") + } + + func testAHealthySyncReadsCompletelyDifferently() { + let healthy = line(liveHr: 3, liveRr: 2, chunks: 9, oHr: 1200, oRr: 2400, oGrav: 8000, + oResp: 8000, oSkin: 8000, oSpo2: 8000, oSteps: 40) + XCTAssertEqual(healthy, + "banked this link: live hr=3 rr=2 | offload hr=1200 rr=2400 gravity=8000 resp=8000" + + " skinTemp=8000 spo2=8000 steps=40") + XCTAssertFalse(healthy.contains("nothing banked")) + } + + func testAPartialOffloadNamesOnlyTheStreamsThatStayedEmpty() { + let l = line(liveHr: 1, chunks: 4, oHr: 500, oRr: 900, oGrav: 0, oResp: 0, oSkin: 700, oSpo2: 700) + XCTAssertTrue(l.contains("nothing banked from the offload for: gravity, resp, steps")) + } + + func testAStreamThisPlatformCannotMeasureIsOmittedNotZero() { + XCTAssertFalse(line(liveHr: 5, chunks: 1, oHr: 10, oSteps: nil).contains("steps")) + } + + func testBatteryNeverAppearsOnEitherPath() { + XCTAssertFalse(line(liveHr: 9, chunks: 1, oHr: 9).contains("battery")) + } + + func testNegativeCountsCannotLeakIntoADiagnostic() { + let l = line(liveHr: -5, liveRr: 1, chunks: 2, oHr: -3, oGrav: 4) + XCTAssertTrue(l.contains("live hr=0 rr=1")) + XCTAssertFalse(l.contains("-3")) + } + + /// Twin of the Kotlin case: "never ran" and "ran with nothing new" are different findings. + func testAnOffloadThatRanWithNothingNewIsADifferentFinding() { + XCTAssertEqual(line(liveHr: 1, liveRr: 1, chunks: 6), + "banked this link: live hr=1 rr=1 | offload ran 6 chunk(s), no new rows") + XCTAssertTrue(line(liveHr: 1, liveRr: 1, chunks: 0).contains("offload none")) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Live5NoGravityRestChainTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Live5NoGravityRestChainTests.swift new file mode 100644 index 0000000000..335577cb8a --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Live5NoGravityRestChainTests.swift @@ -0,0 +1,141 @@ +import XCTest +@testable import StrandAnalytics +import WhoopProtocol +import WhoopStore + +/// The no-gravity Rest chain, from bug #977 (iOS, WHOOP 5.0, live Bluetooth): "Rest score stuck 93 since +/// forever." +/// +/// Renamed from `Live5RestFrozenTests`. The frozen state is closed (see below), and a file called +/// "Frozen" is the same stale signal the `XCTExpectFailure` in it used to be: something a reader greps, +/// believes, and acts on long after it stopped being true. #977 stays named here for archaeology; the +/// file is named for the chain it pins. +/// +/// The mechanism: a LIVE WHOOP 5.0 streams standard 0x2A37 HR continuously, but gravity is only ever +/// populated by the *history offload* (Backfiller). With no overnight gravity the day reaches the scoring +/// loop with dense HR — it clears IntelligenceEngine's `hr.count >= 200` gate, so recovery/Charge still +/// score — while sleep detection finds nothing. No sleep means no `totalSleepMin`/`efficiency`, so +/// `AnalyticsEngine.Rest.composite(daily:)` is nil, so NO `sleep_performance` point is written, and the +/// Rest read-out has nothing new to show. +/// +/// WHAT CHANGED SINCE THESE TESTS WERE WRITTEN, and why they are repinned rather than deleted: +/// +/// • The V1 stager still bails without gravity (`grav.count < 2`). That is unchanged and still pinned +/// below — V1 is a selectable fallback (the 4.0 is unvalidated on either stager, #271/#319), so its +/// behaviour is worth stating, but it is no longer what a default install runs. +/// • The rescue is NOT inside `detectSleep` — neither stager finds a night without motion, and both are +/// pinned saying so below. It is in `IntelligenceEngine`: when a day has no motion, no hypnogram and +/// no stored night, it falls back to `SleepStager.hrOnlySessions(hr:rr:resp:)` (#1801) and feeds the +/// result in as `providedSleep`. #1884 then stopped an all-HR-only night being discarded from the +/// daily aggregates. So the shipping path DOES score a no-gravity night, which is precisely the +/// "HR-only fallback composite" the old fix-contract test named as one of two acceptable fixes. +/// That fallback is covered by `SleepStagerHrOnlySessionsTests` + `AnalyticsEngineHrOnlyDayTests`. +/// +/// The old contract test asserted `Rest.composite(daily:)` on a hand-built row with no sleep fields and +/// wrapped it in `XCTExpectFailure`. That could never pass, whatever got fixed: a row with no aggregates +/// is nil BY DEFINITION, not by bug. It was testing the wrong layer. The chain is pinned at its two real +/// joints instead — does the shipping stager stage the night, and does a staged night yield a composite. +/// +/// The display half (whether Today shows a stale carried value or falls through to its no-data state) is +/// #977's `freshRestScore` + `isCarryStale`, which live in the app target and are covered there and by +/// Android's `RestFreshnessTest`. This package pins only what it owns, and says so at the bottom of the +/// file rather than keeping a test that re-simulates a rule it cannot import. +final class Live5NoGravityRestChainTests: XCTestCase { + + private func hrStream(start: Int, durationS: Int, bpm: Int) -> [HRSample] { + stride(from: 0, to: durationS, by: 1).map { HRSample(ts: start + $0, bpm: bpm) } + } + + /// A late-night start (02:00 UTC, tzOffset 0) so the window is unambiguously overnight and + /// the daytime false-sleep guard is irrelevant to the outcome. + private func nightStart() -> Int { + let refMidnight = 1_749_513_600 // 2026-06-10 00:00:00 UTC + return refMidnight + 2 * 3_600 + } + + // MARK: - Stage 1: no gravity ⇒ no sleep session (the direct BLE-live-5.0 shape) + + /// V1, stated EXPLICITLY rather than taken from the default: 8 h of continuous sleep-plausible HR + /// with zero gravity yields nothing, because V1's spine is motion stillness and there is none. + /// + /// `SleepStagerTests.testDetectSleepEmptyGravity` already asserts the empty-gravity case once, on the + /// default stager. The pair here is not that assertion again: it is the statement that the answer is + /// the SAME on both stagers, which is the fact the chain turns on and the one whose absence let this + /// file spend two years implying V2 might rescue the night. The original spelling relied on + /// `useSleepStagerV2`'s default, so it silently stopped describing shipped behaviour when that + /// default moved — which is how the implication survived. + func testV1WithNoGravityDetectsNoSleep() { + let start = nightStart() + let dur = 8 * 60 * 60 + let hr = hrStream(start: start, durationS: dur, bpm: 50) + let sessions = SleepStager.detectSleep(hr: hr, gravity: [], useSleepStagerV2: false) + XCTAssertTrue(sessions.isEmpty, + "V1's spine is motion stillness: no gravity, no session") + } + + /// V2 too. This is the correction to my first attempt at this test, which asserted that V2 WOULD + /// stage here and failed in CI — rightly. + /// + /// `detectSleep` has no HR-only rescue on either stager: V2's extra machinery is in how it stages a + /// run, not in finding one without motion. The rescue lives one layer up. `IntelligenceEngine` sees + /// the empty result and, when there is no motion, no hypnogram and no stored night, falls back to + /// `SleepStager.hrOnlySessions(hr:rr:resp:)` and feeds the result in as `providedSleep` + /// (`IntelligenceEngine.swift`, gate line `no-motion-no-hypnogram`). That fallback is covered by + /// `SleepStagerHrOnlySessionsTests` and `AnalyticsEngineHrOnlyDayTests`, so it is named here rather + /// than duplicated. + /// + /// Pinning this keeps the two facts adjacent: detection genuinely yields nothing without motion, and + /// that is not the end of the story — which is exactly what the original version of this file got + /// wrong in the other direction, by treating the empty result as the final word on Rest. + func testV2WithNoGravityAlsoDetectsNoSleep() { + let start = nightStart() + let hr = hrStream(start: start, durationS: 8 * 60 * 60, bpm: 50) + let sessions = SleepStager.detectSleep(hr: hr, gravity: [], useSleepStagerV2: true) + XCTAssertTrue(sessions.isEmpty, + "detectSleep needs motion on either stager; the HR-only rescue is the engine's fallback") + } + + // MARK: - Stage 2: no sleep ⇒ no Rest composite ⇒ no sleep_performance point + + /// A DailyMetric shaped exactly as `analyzeDay` leaves it when `matched` is empty: HRV/RHR + /// present (so Charge can still be scored) but no sleep aggregates. This is the row a live-5.0 + /// day produces when gravity never offloaded. + private func chargeableButUnsleptDaily(day: String) -> DailyMetric { + DailyMetric(day: day, + totalSleepMin: nil, // absent ⇒ no Rest composite + efficiency: nil, // absent ⇒ no Rest composite + deepMin: nil, remMin: nil, lightMin: nil, disturbances: nil, + restingHr: 52, // present ⇒ recovery/Charge advances + avgHrv: 65, // present ⇒ recovery/Charge advances + recovery: nil, strain: nil, exerciseCount: nil, + spo2Pct: nil, skinTempDevC: nil, respRateBpm: nil) + } + + func testUnsleptDailyProducesNilRestComposite() { + let daily = chargeableButUnsleptDaily(day: "2026-07-02") + // This is the exact guard at AnalyticsEngine.Rest.composite(daily:) line 696: + // guard let tstMin = d.totalSleepMin, tstMin > 0, let eff = d.efficiency else { return nil } + XCTAssertNil(AnalyticsEngine.Rest.composite(daily: daily), + "No sleep aggregates ⇒ Rest.composite(daily:) is nil ⇒ no sleep_performance point written") + } + + // MARK: - Stage 3: the display-side freeze this produces + + /// Reproduces the Today resolver's tail fallback (iOS LiquidTodayView.swift line 777 / + /// Android TodayScreen.kt line 689): when today has no `sleep_performance` row, both + /// platforms fall back to the latest value in the series. If new nights never write a row, + /// that latest value is pinned to the last night that WAS scored — 93 — forever. + // The downstream joint - a STAGED night yields a composite - deliberately has no test here either. + // `Rest.composite` is exercised in `RestWiringImpactTests`, `RestSubScoreTraceTests`, + // `SleepStageTotalsTests` and `AnalyticsEngineTests`; a fifth assertion that a complete row scores + // would be noise. What is NOT covered elsewhere is the nil case above, which is the one this chain + // turns on, so that is the one that lives here. + + // The display half deliberately has NO test here. It used to, and that test asserted `max(by:)` over a + // literal dictionary — it exercised no product code at all, which is worse than no test because it + // reads like coverage. Since #977 the rule it meant to describe is `freshRestScore` + `isCarryStale`: + // today's own value wins, the carry applies only on today and only inside the freshness window, and + // otherwise the read-out falls through to its no-data state instead of freezing on a weeks-old number. + // Those live in the app target and in Android's `TodayScoring.freshRestScore`, covered by + // `RestFreshnessTest`. This package pins what it owns and points at the rest. +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Live5RestFrozenTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Live5RestFrozenTests.swift deleted file mode 100644 index 31131e97d9..0000000000 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Live5RestFrozenTests.swift +++ /dev/null @@ -1,115 +0,0 @@ -import XCTest -@testable import StrandAnalytics -import WhoopProtocol -import WhoopStore - -/// Bug #977 (iOS, WHOOP 5.0, live Bluetooth): "Rest score stuck 93 since forever." -/// -/// Root cause under test: a LIVE WHOOP 5.0 streams standard 0x2A37 HR continuously, but -/// the accelerometer / gravity stream is only ever populated by the *history offload* -/// (Backfiller). When the overnight gravity has not been offloaded/decoded, the day reaches -/// the scoring loop with dense HR (so it clears IntelligenceEngine's `hr.count >= 200` gate -/// and recovery/Charge can still be computed from HR/HRV), but `SleepStager.detectSleep` -/// bails at `grav.count < 2 { return [] }` — so no sleep is matched, the DailyMetric carries -/// no `totalSleepMin`/`efficiency`, and `AnalyticsEngine.Rest.composite(daily:)` returns nil. -/// -/// A nil composite means NO `sleep_performance` point is written for that night, so the Today -/// display falls back to the tail of the series (iOS `restSeries.last`, Android -/// `byDay.entries.maxByOrNull`) — the last night that WAS scored, e.g. 93 — and Rest is frozen -/// there forever while Charge keeps advancing. -/// -/// These tests pin the mechanism. They are written to FAIL against a fix that makes a live-5.0 -/// night produce SOME advancing Rest signal (whether by an HR-only fallback composite or by -/// forcing a gravity offload before scoring). Today they document the frozen state. -final class Live5RestFrozenTests: XCTestCase { - - private func hrStream(start: Int, durationS: Int, bpm: Int) -> [HRSample] { - stride(from: 0, to: durationS, by: 1).map { HRSample(ts: start + $0, bpm: bpm) } - } - - /// A late-night start (02:00 UTC, tzOffset 0) so the window is unambiguously overnight and - /// the daytime false-sleep guard is irrelevant to the outcome. - private func nightStart() -> Int { - let refMidnight = 1_749_513_600 // 2026-06-10 00:00:00 UTC - return refMidnight + 2 * 3_600 - } - - // MARK: - Stage 1: no gravity ⇒ no sleep session (the direct BLE-live-5.0 shape) - - func testLive5NoGravityDenseHRDetectsNoSleep() { - // 8 h of continuous, sleep-plausible HR (a real live-5.0 night streamed over 0x2A37), - // but ZERO gravity because the accelerometer offload hasn't landed. detectSleep must - // return no sessions — the strap streamed HR but not motion. - let start = nightStart() - let dur = 8 * 60 * 60 - let hr = hrStream(start: start, durationS: dur, bpm: 50) - let sessions = SleepStager.detectSleep(hr: hr, gravity: []) - XCTAssertTrue(sessions.isEmpty, - "A live-5.0 night with HR but no offloaded gravity yields no sleep session") - } - - // MARK: - Stage 2: no sleep ⇒ no Rest composite ⇒ no sleep_performance point - - /// A DailyMetric shaped exactly as `analyzeDay` leaves it when `matched` is empty: HRV/RHR - /// present (so Charge can still be scored) but no sleep aggregates. This is the row a live-5.0 - /// day produces when gravity never offloaded. - private func chargeableButUnsleptDaily(day: String) -> DailyMetric { - DailyMetric(day: day, - totalSleepMin: nil, // absent ⇒ no Rest composite - efficiency: nil, // absent ⇒ no Rest composite - deepMin: nil, remMin: nil, lightMin: nil, disturbances: nil, - restingHr: 52, // present ⇒ recovery/Charge advances - avgHrv: 65, // present ⇒ recovery/Charge advances - recovery: nil, strain: nil, exerciseCount: nil, - spo2Pct: nil, skinTempDevC: nil, respRateBpm: nil) - } - - func testUnsleptDailyProducesNilRestComposite() { - let daily = chargeableButUnsleptDaily(day: "2026-07-02") - // This is the exact guard at AnalyticsEngine.Rest.composite(daily:) line 696: - // guard let tstMin = d.totalSleepMin, tstMin > 0, let eff = d.efficiency else { return nil } - XCTAssertNil(AnalyticsEngine.Rest.composite(daily: daily), - "No sleep aggregates ⇒ Rest.composite(daily:) is nil ⇒ no sleep_performance point written") - } - - // MARK: - Stage 3: the display-side freeze this produces - - /// Reproduces the Today resolver's tail fallback (iOS LiquidTodayView.swift line 777 / - /// Android TodayScreen.kt line 689): when today has no `sleep_performance` row, both - /// platforms fall back to the latest value in the series. If new nights never write a row, - /// that latest value is pinned to the last night that WAS scored — 93 — forever. - // MARK: - The FIX CONTRACT (fails today, passes once #977 is fixed) - - /// The contract a fix must satisfy: a live-5.0 day that has HRV/RHR (Charge advances) but no - /// staged sleep must NOT silently produce a nil Rest signal that freezes the display. Whatever - /// the fix (an HR-only degraded Rest fallback for a chargeable-but-unslept day, OR forcing a - /// gravity offload before scoring so `matched` is non-empty), the observable requirement is: - /// a chargeable day yields SOME non-nil Rest signal so Today advances instead of tailing. - /// - /// This intentionally FAILS against today's code (Rest.composite(daily:) returns nil for such a - /// day). Delete the XCTExpectFailure wrapper when the fix lands. - func testChargeableDayShouldYieldAdvancingRestSignal_FIX() { - XCTExpectFailure("#977 not yet fixed: a chargeable-but-unslept live-5.0 day yields a nil Rest signal") { - let daily = chargeableButUnsleptDaily(day: "2026-07-02") - XCTAssertNotNil(AnalyticsEngine.Rest.composite(daily: daily), - "A day that can score Charge must also surface SOME Rest signal, not freeze") - } - } - - func testTodayRestFreezesOnTailFallbackWhenNoNewPointWritten() { - // sleep_performance series that stopped advancing days ago (last computed night = 93). - let restByDay: [String: Double] = [ - "2026-06-25": 88, - "2026-06-26": 91, - "2026-06-27": 93, // the last night gravity offloaded + scored - ] - let seriesTail = restByDay.max(by: { $0.key < $1.key })?.value // == restSeries.last / maxByOrNull - - // Several later days ran (Charge advanced) but wrote NO sleep_performance row. - for today in ["2026-06-28", "2026-06-29", "2026-06-30", "2026-07-01", "2026-07-02"] { - let restToday = restByDay[today] ?? seriesTail // offset 0 tail fallback - XCTAssertEqual(restToday, 93, - "\(today): Today shows the frozen tail (93), never a fresh score") - } - } -} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LivePersistTraceTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LivePersistTraceTests.swift new file mode 100644 index 0000000000..6c28f59a2f --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LivePersistTraceTests.swift @@ -0,0 +1,140 @@ +import XCTest +@testable import StrandAnalytics + +/// Byte-identical oracle against the Kotlin `liveInsertFailedLine`. The expected strings are the exact +/// ones `StalledLinkDiagnosticsTest` pins on the Android side, so a one-sided wording change fails here +/// rather than drifting apart in the two field logs these are meant to be read beside each other. +final class LivePersistTraceTests: XCTestCase { + func testStandardHRHostReceiptSeparatesAcceptedAndRejectedRows() { + XCTAssertEqual( + LivePersistTrace.standardHRHostReceivedLine( + hostUnixSeconds: 1_750_000_000, + acceptedHRRows: 1, acceptedRRRows: 2, + rejectedHRRows: 0, rejectedRRRows: 1, + pendingHRRows: 4, pendingRRRows: 5 + ), + "standard-hr transport host-received hostUnixSec=1750000000" + + " acceptedHRRows=1 acceptedRRRows=2 rejectedHRRows=0 rejectedRRRows=1" + + " pendingHRRows=4 pendingRRRows=5" + ) + } + + func testStandardHRFlushSuccessSeparatesOfferedFromActuallyInsertedRows() { + XCTAssertEqual( + LivePersistTrace.standardHRFlushAttemptLine( + reason: .cadence, offeredHRRows: 4, offeredRRRows: 5 + ), + "standard-hr transport flush-attempt reason=cadence offeredHRRows=4 offeredRRRows=5" + ) + XCTAssertEqual( + LivePersistTrace.standardHRFlushSucceededLine( + reason: .cadence, offeredHRRows: 4, offeredRRRows: 5, + insertedHRRows: 1, insertedRRRows: 2 + ), + "standard-hr transport flush-succeeded reason=cadence offeredHRRows=4 offeredRRRows=5" + + " insertedHRRows=1 insertedRRRows=2" + ) + } + + func testStandardHRRetryNamesLifecycleReasonAndTotalPendingRows() { + XCTAssertEqual( + LivePersistTrace.standardHRRebufferedForRetryLine( + reason: .disconnect, attemptedHRRows: 1, attemptedRRRows: 2, + pendingHRRows: 3, pendingRRRows: 4, consecutiveFailures: 1 + ), + "standard-hr transport rebuffered-for-retry reason=disconnect" + + " attemptedHRRows=1 attemptedRRRows=2 pendingHRRows=3 pendingRRRows=4" + + " consecutiveFailures=1" + ) + } + + func testStandardHRLifecycleRouteFlushesBackgroundAndTermination() async { + var reasons: [LivePersistTrace.StandardHRFlushReason] = [] + + await StandardHRLifecycleFlush.run(event: .background) { reasons.append($0) } + await StandardHRLifecycleFlush.run(event: .termination) { reasons.append($0) } + + XCTAssertEqual(reasons.map(\.rawValue), ["background", "termination"]) + } + + /// Two live transports fail independently (#1118), so the line must say which. + func testNamesTheFailingTransport() { + XCTAssertTrue(LivePersistTrace.liveInsertFailedLine( + transport: "live-standard", errorName: "E", message: nil, + hrFrames: 1, rrFrames: 1, consecutiveFailures: 1).contains("on live-standard")) + XCTAssertTrue(LivePersistTrace.liveInsertFailedLine( + transport: "live-realtime", errorName: "E", message: nil, + hrFrames: 1, rrFrames: 1, consecutiveFailures: 1).contains("on live-realtime")) + } + + /// One failure is the transient the re-buffer absorbs; a run is a store that will not take these rows. + /// If both rendered the same the count would be decoration. + func testOneFailureReadsTransientAndARunReadsAsNotRecovering() { + let once = LivePersistTrace.liveInsertFailedLine( + transport: "live-standard", errorName: "SQLiteFullException", + message: "database or disk is full", hrFrames: 12, rrFrames: 13, consecutiveFailures: 1) + XCTAssertTrue(once.contains("Re-buffered for the next cadence.")) + XCTAssertFalse(once.contains("consecutive failures")) + + let many = LivePersistTrace.liveInsertFailedLine( + transport: "live-standard", errorName: "SQLiteFullException", + message: "database or disk is full", hrFrames: 12, rrFrames: 13, consecutiveFailures: 9) + XCTAssertTrue(many.contains("9 consecutive failures")) + XCTAssertTrue(many.contains("not recovering them.")) + } + + /// Whole-line equality, not `contains`: this is the assertion that actually holds the two platforms + /// together, since a stray space or a moved clause would still satisfy every check above. + func testWholeLineMatchesTheKotlinRendering() { + XCTAssertEqual( + LivePersistTrace.liveInsertFailedLine( + transport: "live-standard", errorName: "SQLiteFullException", + message: "database or disk is full", hrFrames: 12, rrFrames: 13, consecutiveFailures: 9), + "Live persist FAILED on live-standard — SQLiteFullException: database or disk is full" + + " (hr=12 rr=13). 9 consecutive failures — these rows are not landing and the re-buffer" + + " is not recovering them.") + XCTAssertEqual( + LivePersistTrace.liveInsertFailedLine( + transport: "live-realtime", errorName: "IllegalStateException", message: nil, + hrFrames: 0, rrFrames: 4, consecutiveFailures: 1), + "Live persist FAILED on live-realtime — IllegalStateException (hr=0 rr=4)." + + " Re-buffered for the next cadence.") + } + + func testMessageSurvivesAndIsBounded() { + let line = LivePersistTrace.liveInsertFailedLine( + transport: "live-standard", errorName: "IllegalStateException", + message: String(repeating: "x", count: 500), hrFrames: 1, rrFrames: 2, consecutiveFailures: 1) + XCTAssertTrue(line.contains(String(repeating: "x", count: 200))) + XCTAssertFalse(line.contains(String(repeating: "x", count: 201))) + } + + func testBlankOrAbsentMessageLeavesNoDanglingSeparator() { + for message in [nil, " "] as [String?] { + let line = LivePersistTrace.liveInsertFailedLine( + transport: "live-standard", errorName: "IllegalStateException", message: message, + hrFrames: 1, rrFrames: 2, consecutiveFailures: 1) + XCTAssertFalse(line.contains(": ("), "a blank message must not leave a dangling colon") + } + } + + // MARK: - rate limit + + /// The first failure is the one most worth having, so "never emitted" must not read as "just emitted". + func testFirstFailureAlwaysEmits() { + XCTAssertTrue(LivePersistTrace.shouldEmitLiveInsertFailure(lastEmitMs: 0, nowMs: 1_000)) + XCTAssertTrue(LivePersistTrace.shouldEmitLiveInsertFailure(lastEmitMs: -5, nowMs: 1_000)) + } + + func testGapIsHonouredAtItsBoundary() { + XCTAssertFalse(LivePersistTrace.shouldEmitLiveInsertFailure(lastEmitMs: 1_000, nowMs: 60_999)) + XCTAssertTrue(LivePersistTrace.shouldEmitLiveInsertFailure(lastEmitMs: 1_000, nowMs: 61_000)) + } + + /// A clock that steps backwards must not latch the line off until real time catches up. + func testBackwardsClockEmitsRatherThanLatchingOff() { + XCTAssertTrue(LivePersistTrace.shouldEmitLiveInsertFailure(lastEmitMs: 10_000, nowMs: 5_000)) + XCTAssertTrue(LivePersistTrace.shouldEmitLiveInsertFailure( + lastEmitMs: Int64.max / 2, nowMs: 1_000)) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LocalDayWindowsTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LocalDayWindowsTests.swift new file mode 100644 index 0000000000..f060d7e64e --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LocalDayWindowsTests.swift @@ -0,0 +1,434 @@ +import Foundation +import XCTest +@testable import StrandAnalytics + +/// The day-window primitive, one test per scenario of the `daily-windows` specification, plus the +/// parity oracle that the Kotlin twin reads from the same committed file. +/// +/// Every fixture passes an explicit zone and an explicit reference instant, so nothing here depends on +/// the machine's zone or on when the suite runs. The two exceptions are the default-accessor tests, +/// which are about the defaults themselves and deliberately do not go through the oracle. +final class LocalDayWindowsTests: XCTestCase { + + // MARK: - Fixture helpers + + /// A date written the way the specification writes it. + private func date(_ text: String) -> LocalCalendarDate { + let parts = text.split(separator: "-").map { Int($0)! } + return LocalCalendarDate(year: parts[0], month: parts[1], day: parts[2]) + } + + /// An instant from whole seconds since the epoch. + private func instant(_ epochSeconds: Int) -> Date { + Date(timeIntervalSince1970: TimeInterval(epochSeconds)) + } + + /// A UTC instant written as `YYYY-MM-DDTHH:MM:SSZ`, parsed without a formatter so the test's own + /// expectations do not depend on a locale or a calendar. + private func utc(_ text: String) -> Date { + let halves = text.dropLast().split(separator: "T") + let day = halves[0].split(separator: "-").map { Int($0)! } + let time = halves[1].split(separator: ":").map { Int($0)! } + let days = LocalCalendarDate(year: day[0], month: day[1], day: day[2]).daysSinceEpoch + return instant(days * 86_400 + time[0] * 3600 + time[1] * 60 + time[2]) + } + + /// The helper for a zone, with a reference instant that no scenario in this section depends on. + private func windows(_ zoneName: String, + reference: Date = Date(timeIntervalSince1970: 0)) -> LocalDayWindows { + LocalDayWindows(timeZone: TimeZone(identifier: zoneName)!, referenceInstant: reference) + } + + // MARK: - A local date begins at its own local midnight + + /// The discriminating case: the helper and the analysis core's fixed-offset arithmetic disagree by + /// an hour across a transition. + /// + /// The core's arithmetic is re-derived inline, verbatim per the scenario, because the incumbent + /// lives in the app target and a package test cannot import it: floor to a local midnight using the + /// zone's offset at the reference instant, then subtract whole 86,400-second days. + func testResolvedStartDiffersFromFixedOffsetArithmeticAcrossATransition() { + let zone = TimeZone(identifier: "America/New_York")! + let reference = utc("2025-11-10T17:00:00Z") // 12:00 local, inside standard time + let helper = LocalDayWindows(timeZone: zone, referenceInstant: reference) + + let resolved = helper.start(of: date("2025-10-19")) + + let offset = TimeInterval(zone.secondsFromGMT(for: reference)) + let localSeconds = reference.timeIntervalSince1970 + offset + let referenceMidnight = (localSeconds / 86_400).rounded(.down) * 86_400 - offset + let fixedOffsetStart = Date(timeIntervalSince1970: referenceMidnight - 22 * 86_400) + + XCTAssertEqual(resolved, utc("2025-10-19T04:00:00Z")) + XCTAssertEqual(fixedOffsetStart, utc("2025-10-19T05:00:00Z")) + XCTAssertNotEqual(resolved, fixedOffsetStart) + } + + /// Two reference instants on opposite sides of the zone's autumn transition give the same start. + func testResultDoesNotDependOnTheReferenceInstant() { + let before = windows("America/New_York", reference: utc("2025-10-15T12:00:00Z")) + let after = windows("America/New_York", reference: utc("2025-12-15T12:00:00Z")) + + XCTAssertEqual(before.start(of: date("2025-10-19")), utc("2025-10-19T04:00:00Z")) + XCTAssertEqual(after.start(of: date("2025-10-19")), utc("2025-10-19T04:00:00Z")) + } + + /// A zone offset of 5 hours 45 minutes puts the start on its own local midnight, off the hour. + func testDayWithFortyFiveMinuteOffsetStartsAtItsOwnLocalMidnight() { + let helper = windows("Asia/Kathmandu") + + let start = helper.start(of: date("2025-06-15")) + + XCTAssertEqual(start, utc("2025-06-14T18:15:00Z")) + XCTAssertEqual(helper.offsetSeconds(at: start!), 20_700) + XCTAssertNotEqual(Int(start!.timeIntervalSince1970) % 3600, 0) + } + + // MARK: - A day window lasts as long as the zone says and carries its own offset + + /// The spring-forward day is 23 hours long. + func testSpringForwardDayIsTwentyThreeHours() { + let window = windows("America/New_York").window(of: date("2025-03-09")) + + XCTAssertEqual(window?.duration, 23 * 3600) + } + + /// The fall-back day is 25 hours long. + func testFallBackDayIsTwentyFiveHours() { + let window = windows("America/New_York").window(of: date("2025-11-02")) + + XCTAssertEqual(window?.duration, 25 * 3600) + } + + /// A zone without daylight saving has 24-hour days. + func testDayInZoneWithoutDaylightSavingIsTwentyFourHours() { + let window = windows("Asia/Kolkata").window(of: date("2025-06-15")) + + XCTAssertEqual(window?.duration, 24 * 3600) + } + + /// Consecutive windows meet exactly across a transition, leaving neither a gap nor an overlap. + func testConsecutiveWindowsMeetExactly() throws { + let helper = windows("America/New_York") + + let first = try XCTUnwrap(helper.window(of: date("2025-11-01"))) + let second = try XCTUnwrap(helper.window(of: date("2025-11-02"))) + let third = try XCTUnwrap(helper.window(of: date("2025-11-03"))) + + XCTAssertEqual(first.nextStart, second.start) + XCTAssertEqual(second.nextStart, third.start) + } + + /// The window reports the offset in effect at its own start, not at some other moment of the day. + func testWindowReportsOffsetInEffectAtItsStart() { + let helper = windows("America/New_York") + + XCTAssertEqual(helper.window(of: date("2025-11-01"))?.utcOffsetSeconds, -14_400) + XCTAssertEqual(helper.window(of: date("2025-11-03"))?.utcOffsetSeconds, -18_000) + } + + // MARK: - The helper resolves the start of the local day containing an instant + + /// An instant inside the repeated local hour still resolves to that day's start. + func testInstantInsideTwentyFiveHourDayResolvesToThatDaysStart() { + let helper = windows("America/New_York") + + let start = helper.startOfDay(containing: utc("2025-11-02T05:30:00Z")) + + XCTAssertEqual(start, utc("2025-11-02T04:00:00Z")) + } + + /// A start instant resolves to itself, for every fixture date that exists. + func testContainingDayStartOfAStartInstantIsItself() { + for (zone, day) in [("America/New_York", "2025-03-09"), + ("America/New_York", "2025-11-02"), + ("America/New_York", "2025-10-19"), + ("America/Santiago", "2025-09-07"), + ("America/Havana", "2025-11-02"), + ("Pacific/Apia", "2011-12-31"), + ("Asia/Kolkata", "2025-06-15"), + ("Asia/Kathmandu", "2025-06-15")] { + let helper = windows(zone) + let start = helper.start(of: date(day)) + XCTAssertNotNil(start, "\(zone) \(day) must have a start") + if let start { + XCTAssertEqual(helper.startOfDay(containing: start), start, "\(zone) \(day)") + } + } + } + + // MARK: - An instant's day key names the window that contains it + + /// The key flips exactly at the window boundary and nowhere else. + func testDayKeyChangesExactlyAtTheWindowBoundary() { + let helper = windows("America/New_York") + let start = helper.start(of: date("2025-11-02"))! + + XCTAssertEqual(helper.dayKey(for: start.addingTimeInterval(-1)), "2025-11-01") + XCTAssertEqual(helper.dayKey(for: start.addingTimeInterval(1)), "2025-11-02") + } + + /// The key holds for all 25 hours of the fall-back day, including the repeated local hour. + func testDayKeyHoldsAcrossTheWholeOfATwentyFiveHourDay() { + let helper = windows("America/New_York") + let window = helper.window(of: date("2025-11-02"))! + + XCTAssertEqual(helper.dayKey(for: window.start), "2025-11-02") + XCTAssertEqual(helper.dayKey(for: utc("2025-11-02T05:30:00Z")), "2025-11-02") + XCTAssertEqual(helper.dayKey(for: window.nextStart.addingTimeInterval(-1)), "2025-11-02") + } + + // MARK: - An ambiguous or missing local midnight resolves by a stated rule + + /// A date with no 00:00 starts at its first existing instant, its local 01:00. + func testTransitionThatSkipsLocalMidnight() { + let start = windows("America/Santiago").start(of: date("2025-09-07")) + + XCTAssertEqual(start, utc("2025-09-07T04:00:00Z")) + } + + /// A date with two 00:00 starts at the earlier of them. + func testTransitionThatRepeatsLocalMidnight() { + let helper = windows("America/Havana") + + let start = helper.start(of: date("2025-11-02")) + + XCTAssertEqual(start, utc("2025-11-02T04:00:00Z")) + XCTAssertEqual(helper.offsetSeconds(at: start!), -14_400) + // The later of the two midnights, which the rule must not pick. + XCTAssertEqual(helper.offsetSeconds(at: utc("2025-11-02T05:00:00Z")), -18_000) + } + + // MARK: - A date that does not exist is skipped over + + /// A calendar date the zone skipped has no start at all. + func testSkippedCalendarDateProducesNoStart() { + XCTAssertNil(windows("Pacific/Apia").start(of: date("2011-12-30"))) + } + + /// The preceding date's window reaches over the skipped date to the next existing one. + func testPrecedingDatesWindowReachesTheNextExistingDate() { + let helper = windows("Pacific/Apia") + + let window = helper.window(of: date("2011-12-29")) + + XCTAssertEqual(window?.nextStart, helper.start(of: date("2011-12-31"))) + XCTAssertEqual(window?.duration, 24 * 3600) + } + + // MARK: - A backward run yields consecutive existing dates with their own starts + + /// A run spanning the autumn transition is consecutive and each entry carries its own start. + func testRunAcrossATransitionIsConsecutiveAndCorrectlyAnchored() { + let helper = windows("America/New_York") + + let run = helper.backwardRun(endingAt: date("2025-11-10"), count: 21) + + XCTAssertEqual(run.count, 21) + XCTAssertEqual(run.first?.date.key, "2025-10-21") + XCTAssertEqual(run.last?.date.key, "2025-11-10") + for (index, entry) in run.enumerated() { + XCTAssertEqual(entry.date.daysSinceEpoch, + date("2025-10-21").daysSinceEpoch + index, + "run must be consecutive at \(entry.date.key)") + XCTAssertEqual(entry.start, helper.start(of: entry.date), entry.date.key) + } + } + + /// A run containing a skipped date is shorter than requested and omits it. + func testRunContainingASkippedDateIsShorterThanRequested() { + let run = windows("Pacific/Apia").backwardRun(endingAt: date("2012-01-02"), count: 5) + + XCTAssertEqual(run.count, 4) + XCTAssertEqual(run.first?.date.key, "2011-12-29") + XCTAssertEqual(run.map(\.date.key), ["2011-12-29", "2011-12-31", "2012-01-01", "2012-01-02"]) + } + + /// A long run lands on the right calendar date, 3999 days before the anchor. + func testLongRunReachesTheRightCalendarDate() { + let run = windows("America/New_York").backwardRun(endingAt: date("2025-11-10"), count: 4000) + + XCTAssertEqual(run.count, 4000) + XCTAssertEqual(run.first?.date.key, "2014-11-29") + XCTAssertEqual(run.last?.date.key, "2025-11-10") + } + + // MARK: - The sleep read window never extends into the future + + /// The current date's window stops at the reference instant instead of its next start. + func testCurrentDatesWindowStopsAtTheReferenceInstant() { + let helper = windows("America/New_York", reference: utc("2025-11-02T18:00:00Z")) + + let end = helper.sleepReadWindowEnd(of: date("2025-11-02")) + + XCTAssertEqual(end, utc("2025-11-02T18:00:00Z")) + XCTAssertNotEqual(end, helper.window(of: date("2025-11-02"))?.nextStart) + } + + /// A past 25-hour day reaches its own next start, 25 hours after it began. + func testPastTwentyFiveHourDayReachesItsOwnNextStart() { + let helper = windows("America/New_York", reference: utc("2025-12-15T12:00:00Z")) + + let end = helper.sleepReadWindowEnd(of: date("2025-11-02")) + let window = helper.window(of: date("2025-11-02"))! + + XCTAssertEqual(end, window.nextStart) + XCTAssertEqual(end?.timeIntervalSince(window.start), 25 * 3600) + } + + // MARK: - The default zone and reference instant are named, not implied + + /// The default zone is the auto-updating current zone named in the design, not a snapshot of it. + func testDefaultZoneIsThePlatformsTrackingDeviceZone() { + XCTAssertEqual(LocalDayWindows.defaultTimeZone, TimeZone.autoupdatingCurrent) + XCTAssertEqual(LocalDayWindows().timeZone, TimeZone.autoupdatingCurrent) + XCTAssertEqual(LocalDayWindows(referenceInstant: Date()).timeZone, TimeZone.autoupdatingCurrent) + } + + /// The default reference instant is the current `Date`, so it lies inside a bracket taken around + /// the call. + func testDefaultReferenceInstantIsThePlatformsCurrentInstant() { + let before = Date() + let accessor = LocalDayWindows.defaultReferenceInstant + let helperDefault = LocalDayWindows().referenceInstant + let after = Date() + + XCTAssertGreaterThanOrEqual(accessor, before) + XCTAssertLessThanOrEqual(accessor, after) + XCTAssertGreaterThanOrEqual(helperDefault, before) + XCTAssertLessThanOrEqual(helperDefault, after) + } + + // MARK: - Both platforms agree, proven by a committed oracle + + /// The committed oracle, loaded by a path derived from this file's own location. + /// + /// It fails rather than skips when the file is absent: an oracle nobody can find would otherwise + /// let both platforms stay green while they disagree, which is the whole thing this file guards. + private func loadOracle() throws -> [String: Any] { + let relative = "android/app/src/test/resources/local_day_windows_oracle.json" + var dir = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + for _ in 0..<8 { + let candidate = dir.appendingPathComponent(relative) + if FileManager.default.fileExists(atPath: candidate.path) { + let data = try Data(contentsOf: candidate) + let parsed = try JSONSerialization.jsonObject(with: data) + return try XCTUnwrap(parsed as? [String: Any], "the oracle must be a JSON object") + } + dir = dir.deletingLastPathComponent() + } + XCTFail("committed oracle \(relative) not found above \(#filePath) — this test must not pass by default") + throw CocoaError(.fileNoSuchFile) + } + + /// Every value in the committed oracle, re-derived by the helper and compared. + /// + /// The Kotlin twin asserts the same file, so a change on either side reddens one of the two suites. + func testSwiftTestReadsTheSameCommittedFile() throws { + let oracle = try loadOracle() + + let producedBy = try XCTUnwrap(oracle["producedBy"] as? [String: Any]) + XCTAssertFalse(try XCTUnwrap(producedBy["toolchain"] as? String).isEmpty) + XCTAssertFalse(try XCTUnwrap(producedBy["timeZoneDataVersion"] as? String).isEmpty) + + let recorded = try XCTUnwrap(producedBy["timeZoneDataVersion"] as? String) + let note = LocalDayWindows.timeZoneDataVersionNote( + recorded: recorded, observed: LocalDayWindows.observedTimeZoneDataVersion) + + let starts = try XCTUnwrap(oracle["starts"] as? [[String: Any]]) + XCTAssertEqual(starts.count, 19, "oracle section starts") + for record in starts { + let helper = windows(try XCTUnwrap(record["zone"] as? String)) + let day = date(try XCTUnwrap(record["date"] as? String)) + let expected = record["start"] as? Int + let context = "start \(helper.timeZone.identifier) \(day.key) — \(note)" + XCTAssertEqual(helper.start(of: day).map { Int($0.timeIntervalSince1970) }, expected, context) + } + + let windowRecords = try XCTUnwrap(oracle["windows"] as? [[String: Any]]) + XCTAssertEqual(windowRecords.count, 18, "oracle section windows") + for record in windowRecords { + let helper = windows(try XCTUnwrap(record["zone"] as? String)) + let day = date(try XCTUnwrap(record["date"] as? String)) + let window = try XCTUnwrap(helper.window(of: day)) + let context = "window \(helper.timeZone.identifier) \(day.key) — \(note)" + XCTAssertEqual(Int(window.start.timeIntervalSince1970), record["start"] as? Int, context) + XCTAssertEqual(Int(window.nextStart.timeIntervalSince1970), record["nextStart"] as? Int, context) + XCTAssertEqual(window.utcOffsetSeconds, record["utcOffsetSeconds"] as? Int, context) + } + + let containing = try XCTUnwrap(oracle["containingDayStarts"] as? [[String: Any]]) + XCTAssertEqual(containing.count, 8, "oracle section containingDayStarts") + for record in containing { + let helper = windows(try XCTUnwrap(record["zone"] as? String)) + let at = instant(try XCTUnwrap(record["instant"] as? Int)) + let context = "containing \(helper.timeZone.identifier) \(record["instant"] ?? "?") — \(note)" + XCTAssertEqual(Int(helper.startOfDay(containing: at).timeIntervalSince1970), + record["start"] as? Int, context) + } + + let keys = try XCTUnwrap(oracle["dayKeys"] as? [[String: Any]]) + XCTAssertEqual(keys.count, 11, "oracle section dayKeys") + for record in keys { + let helper = windows(try XCTUnwrap(record["zone"] as? String)) + let at = instant(try XCTUnwrap(record["instant"] as? Int)) + let context = "key \(helper.timeZone.identifier) \(record["instant"] ?? "?") — \(note)" + XCTAssertEqual(helper.dayKey(for: at), record["key"] as? String, context) + } + + let runs = try XCTUnwrap(oracle["runs"] as? [[String: Any]]) + XCTAssertEqual(runs.count, 4, "oracle section runs") + for record in runs { + let helper = windows(try XCTUnwrap(record["zone"] as? String)) + let anchor = date(try XCTUnwrap(record["endingAt"] as? String)) + let requested = try XCTUnwrap(record["requested"] as? Int) + let run = helper.backwardRun(endingAt: anchor, count: requested) + let context = "run \(helper.timeZone.identifier) \(anchor.key)/\(requested) — \(note)" + XCTAssertEqual(run.count, record["size"] as? Int, context) + for (edge, entry) in [("oldest", run.first), ("newest", run.last)] { + let expected = try XCTUnwrap(record[edge] as? [String: Any], context) + XCTAssertEqual(entry?.date.key, expected["date"] as? String, "\(edge) \(context)") + XCTAssertEqual(entry.map { Int($0.start.timeIntervalSince1970) }, + expected["start"] as? Int, "\(edge) \(context)") + } + // Short runs carry every entry; the 4000-day run carries its edges and its size only, so + // that the committed file stays reviewable, and says so in `daysTruncated`. + let days = try XCTUnwrap(record["days"] as? [[String: Any]], context) + if try XCTUnwrap(record["daysTruncated"] as? Bool, context) { + XCTAssertTrue(days.isEmpty, context) + } else { + XCTAssertEqual(days.count, run.count, context) + for (entry, expected) in zip(run, days) { + XCTAssertEqual(entry.date.key, expected["date"] as? String, context) + XCTAssertEqual(Int(entry.start.timeIntervalSince1970), expected["start"] as? Int, context) + } + } + } + + let sleepEnds = try XCTUnwrap(oracle["sleepWindowEnds"] as? [[String: Any]]) + XCTAssertEqual(sleepEnds.count, 5, "oracle section sleepWindowEnds") + for record in sleepEnds { + let reference = instant(try XCTUnwrap(record["referenceInstant"] as? Int)) + let helper = windows(try XCTUnwrap(record["zone"] as? String), reference: reference) + let day = date(try XCTUnwrap(record["date"] as? String)) + let context = "sleep end \(helper.timeZone.identifier) \(day.key) — \(note)" + XCTAssertEqual(helper.sleepReadWindowEnd(of: day).map { Int($0.timeIntervalSince1970) }, + record["end"] as? Int, context) + } + } + + /// The mismatch note names the recorded version and the observed one, and says so plainly where the + /// platform has no version of its own to report. + func testMismatchNoteNamesBothDatabaseVersions() { + let observed = LocalDayWindows.timeZoneDataVersionNote(recorded: "2025b", observed: "2025a") + XCTAssertEqual(observed, + "time-zone database version: the oracle records 2025b; this platform observes 2025a.") + + let blind = LocalDayWindows.timeZoneDataVersionNote(recorded: "unavailable", observed: nil) + XCTAssertEqual(blind, + "time-zone database version: the oracle records unavailable; " + + "this platform cannot observe its own time-zone database version.") + XCTAssertTrue(blind.contains("cannot observe")) + XCTAssertTrue(blind.contains("unavailable")) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/PreSleepHeartRateFeedbackTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/PreSleepHeartRateFeedbackTests.swift new file mode 100644 index 0000000000..0dd4ad071a --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/PreSleepHeartRateFeedbackTests.swift @@ -0,0 +1,259 @@ +import XCTest +@testable import StrandAnalytics +import WhoopProtocol +import WhoopStore + +final class PreSleepHeartRateFeedbackTests: XCTestCase { + private func samples(_ start: Int, bpm: Int, count: Int) -> [HRSample] { + (0.. (hr: [HRSample], start: Int, end: Int) { - var hr: [HRSample] = [] - let totalSeconds = Int(hours * 3600) - var t = 0 - while t < totalSeconds { - let bpm = startBpm + slopePerHour * (Double(t) / 3600.0) - hr.append(HRSample(ts: originTs + t, bpm: Int(bpm.rounded()))) - t += 30 - } - return (hr, originTs, originTs + totalSeconds) - } - - func testRecoveryIndexSlopeNilWhenNoSamples() { - XCTAssertNil(RecoveryScorer.recoveryIndexSlope([], start: 0, end: 1000)) - } - - func testRecoveryIndexSlopeNilWhenTooFewBins() { - // Only 2 five-minute bins (10 minutes) of data — below recoveryIndexMinBins — too - // little of the night to fit a trend; must return nil, never a fabricated slope. - var hr: [HRSample] = [] - let start = 5000 - for i in 0..<300 { hr.append(HRSample(ts: start + i, bpm: 60)) } - for i in 0..<300 { hr.append(HRSample(ts: start + 300 + i, bpm: 55)) } - XCTAssertNil(RecoveryScorer.recoveryIndexSlope(hr, start: start, end: start + 600)) - } - - func testRecoveryIndexSlopeRecoversMultipleDistinctInjectedSlopes() { - // Derived-signal rule: recover MULTIPLE distinct injected slopes, not one matched case. - // Four full-night (6 h) synthetic series: flat, mild decline, steep decline, rising. - let flat = slopeSeries(startBpm: 62, slopePerHour: 0.0, hours: 6) - let mild = slopeSeries(startBpm: 62, slopePerHour: -1.0, hours: 6) - let steep = slopeSeries(startBpm: 68, slopePerHour: -4.0, hours: 6) - let rising = slopeSeries(startBpm: 55, slopePerHour: 2.0, hours: 6) - - let flatSlope = RecoveryScorer.recoveryIndexSlope(flat.hr, start: flat.start, end: flat.end) - let mildSlope = RecoveryScorer.recoveryIndexSlope(mild.hr, start: mild.start, end: mild.end) - let steepSlope = RecoveryScorer.recoveryIndexSlope(steep.hr, start: steep.start, end: steep.end) - let risingSlope = RecoveryScorer.recoveryIndexSlope(rising.hr, start: rising.start, end: rising.end) - - XCTAssertNotNil(flatSlope); XCTAssertNotNil(mildSlope) - XCTAssertNotNil(steepSlope); XCTAssertNotNil(risingSlope) - - // Each recovered slope is close to its OWN injected value (within integer-bpm rounding - // noise from the synthetic series), not just relatively ordered. - XCTAssertEqual(flatSlope!, 0.0, accuracy: 0.3) - XCTAssertEqual(mildSlope!, -1.0, accuracy: 0.3) - XCTAssertEqual(steepSlope!, -4.0, accuracy: 0.3) - XCTAssertEqual(risingSlope!, 2.0, accuracy: 0.3) - - // And strictly ordered steep-decline < mild-decline < flat < rising. - XCTAssertLessThan(steepSlope!, mildSlope!) - XCTAssertLessThan(mildSlope!, flatSlope!) - XCTAssertLessThan(flatSlope!, risingSlope!) - } - // MARK: - Recovery Index / Activity-Balance folded into recovery(...) func testRecoveryIndexAndActivityBalanceDefaultNilByteIdenticalToBefore() { diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RhrBinGateDiagnosticTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RhrBinGateDiagnosticTests.swift new file mode 100644 index 0000000000..ec5b5c4a1a --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RhrBinGateDiagnosticTests.swift @@ -0,0 +1,86 @@ +import XCTest +import WhoopProtocol +@testable import StrandAnalytics + +/// #1943 conformance check: the line reports when the gate MOVED the floor by comparing the UNGATED +/// floor (the old rule: min over every non-empty bin) against the shipped one (which IS the gated +/// floor). The line fires when the gate excluded the bin that would otherwise have won — which is +/// the frequency and magnitude the measure-only diagnostic was meant to learn. Byte-parity twin of +/// Kotlin `RhrBinGateDiagnosticTest`. +final class RhrBinGateDiagnosticTests: XCTestCase { + + private func hr(_ start: Int, _ count: Int, _ bpm: Int) -> [HRSample] { + (0.. StepSample { + StepSample(ts: ts, counter: counter, activityClass: cls) + } + private func sleep(stages: [StageSegment] = []) -> SleepSession { + SleepSession(start: 0, end: 100, efficiency: 1, stages: stages, restingHR: nil, avgHRV: nil) + } + + func testIsolatedBedMotionIsRejected() { + XCTAssertNil(SleepAwareStepCounter.stepsInWindow([ + sample(0, 0), sample(1, 1), sample(20, 2) + ], sleepSessions: [sleep()])) + } + + func testCoherentNightWalkCounts() { + XCTAssertEqual(SleepAwareStepCounter.stepsInWindow([ + sample(0, 0), sample(1, 2), sample(2, 4), sample(3, 6), sample(4, 8), sample(5, 10) + ], sleepSessions: [sleep()]), 10) + } + + func testExplicitWakeGapCountsImmediately() { + let wake = StageSegment(start: 10, end: 20, stage: "wake") + XCTAssertEqual(SleepAwareStepCounter.stepsInWindow([ + sample(9, 0), sample(10, 2) + ], sleepSessions: [sleep(stages: [wake])]), 2) + } + + func testDiagnosticRejectionReasonsAreSeparated() { + let count = SleepAwareStepCounter.count([ + sample(0, 100, 0), sample(1, 102, 1), sample(2, 110, 1), + sample(4, 117, 2), sample(5, 120, 0) + ], sleepSessions: []) + XCTAssertEqual(count.totalTicks, 9) + XCTAssertEqual(count.rejectedActivityClassTicks, 3) + XCTAssertEqual(count.rejectedImplausibleTicks, 8) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepDebtTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepDebtTests.swift index 1a2eebd690..b1720de223 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepDebtTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepDebtTests.swift @@ -15,19 +15,18 @@ final class SleepDebtTests: XCTestCase { XCTAssertEqual(l.needMin, 480.0, accuracy: 1e-9) } - /// A surplus night offsets a deficit one (ledger nets credits and debits). - func testSurplusOffsetsDeficit() { + /// Debt carries forward as part of the following night's need; a night that meets + /// that complete need clears it instead of producing a positive surplus. + func testDebtRecursAndMeetingNeedClearsIt() { let series: [(day: String, totalSleepMin: Double?)] = [ ("2026-06-01", 360), // −120 - ("2026-06-02", 540), // +60 - ("2026-06-03", 420), // −60 + ("2026-06-02", 546), // base 480 + prior debt 66 + ("2026-06-03", 540), // raw bar remains +60 ] - // need 8 h = 480. Σ = −120 + 60 − 60 = −120. let l = SleepDebt.ledger(series: series, needHours: 8.0) - XCTAssertEqual(l.balanceMin, -120.0, accuracy: 1e-9) - XCTAssertTrue(l.isDebt) - XCTAssertEqual(l.magnitudeMin, 120.0, accuracy: 1e-9) - XCTAssertEqual(l.nights.map { $0.deltaMin }, [-120, 60, -60]) + XCTAssertEqual(l.balanceMin, 0.0, accuracy: 1e-9) + XCTAssertFalse(l.isDebt) + XCTAssertEqual(l.nights.map { $0.deltaMin }, [-120, 66, 60]) } /// Nights with no usable sleep are skipped entirely (never zero-filled as debt). @@ -40,19 +39,20 @@ final class SleepDebtTests: XCTestCase { ] let l = SleepDebt.ledger(series: series, needHours: 8.0) XCTAssertEqual(l.nightCount, 2) - XCTAssertEqual(l.balanceMin, -60.0, accuracy: 1e-9) + XCTAssertEqual(l.balanceMin, -33.0, accuracy: 1e-9) XCTAssertEqual(l.nights.map { $0.day }, ["2026-06-01", "2026-06-04"]) } /// Only the most-recent `window` COUNTED nights are in scope. func testWindowCapKeepsMostRecent() { - // 16 nights, each 60 min UNDER need → each delta −60. + // 16 nights, each 60 min UNDER base need. The recurrence is computed only + // across the retained 14 counted nights, preserving the existing window. let series: [(day: String, totalSleepMin: Double?)] = (1...16).map { (String(format: "2026-06-%02d", $0), Double(420)) } let l = SleepDebt.ledger(series: series, needHours: 8.0, window: 14) XCTAssertEqual(l.nightCount, 14) // capped - XCTAssertEqual(l.balanceMin, -840.0, accuracy: 1e-9) // 14 × −60 + XCTAssertEqual(l.balanceMin, -73.3, accuracy: 1e-9) XCTAssertEqual(l.nights.first?.day, "2026-06-03") // oldest kept XCTAssertEqual(l.nights.last?.day, "2026-06-16") // newest kept } @@ -72,7 +72,7 @@ final class SleepDebtTests: XCTestCase { func testDefaultNeedIsEightHours() { let l = SleepDebt.ledger(series: [("2026-06-01", 420)]) XCTAssertEqual(l.needMin, AnalyticsEngine.Rest.defaultNeedHours * 60.0, accuracy: 1e-9) - XCTAssertEqual(l.balanceMin, -60.0, accuracy: 1e-9) + XCTAssertEqual(l.balanceMin, -33.0, accuracy: 1e-9) } /// Main sleep remains the canonical nightly figure, while a separately-recorded nap @@ -81,7 +81,7 @@ final class SleepDebtTests: XCTestCase { let credited = try XCTUnwrap(SleepDebt.creditedSleepMin(mainSleepMin: 392, napSleepMin: 48)) XCTAssertEqual(credited, 440, accuracy: 1e-9) let l = SleepDebt.ledger(series: [("2026-06-01", credited)], needHours: 8.0) - XCTAssertEqual(l.balanceMin, -40, accuracy: 1e-9) + XCTAssertEqual(l.balanceMin, -22, accuracy: 1e-9) } func testNapCreditRequiresMainSleepAndIgnoresNegativeNapMinutes() { @@ -90,20 +90,82 @@ final class SleepDebtTests: XCTestCase { XCTAssertEqual(SleepDebt.creditedSleepMin(mainSleepMin: 392, napSleepMin: -10), 392.0) } - /// A NEGATIVE EXACT half-tie balance rounds AWAY from zero (−0.05 → −0.1), the documented - /// `round1` contract the Kotlin mirror must match (audit #6). needMin = 0.1, slept 0.05 → - /// delta −0.05 exactly → balance −0.1. Kotlin's old `roundToInt()` (half toward +∞) gave - /// 0.0 on this exact tie — the real divergence this pins shut. - func testNegativeHalfTieRoundsAwayFromZero() { - let l = SleepDebt.ledger(series: [("2026-06-01", 0.05)], needHours: 0.1 / 60.0) - XCTAssertEqual(l.balanceMin, -0.1, accuracy: 1e-9) // away-from-zero, not 0.0 + func testDebtBelowTenMinutesClearsButExactlyTenRemains() { + XCTAssertEqual(SleepDebt.onTargetBandMin, 10, accuracy: 1e-9) + // 18 min shortfall × 0.55 = 9.9 min -> cleared. + XCTAssertEqual( + SleepDebt.ledger(series: [("2026-06-01", 462)], needHours: 8).balanceMin, + 0, + accuracy: 1e-9) + // A deliberately exact 10 min calculated debt remains debt. + let slept = 480.0 - (10.0 / 0.55) + XCTAssertEqual( + SleepDebt.ledger(series: [("2026-06-01", slept)], needHours: 8).balanceMin, + -10, + accuracy: 1e-9) + } + + func testSurplusNeverCreatesPositiveBalanceAndRawBarsStayUnchanged() { + let l = SleepDebt.ledger(series: [ + ("2026-06-01", 600), + ("2026-06-02", 420), + ], needHours: 8) + XCTAssertEqual(l.balanceMin, -33, accuracy: 1e-9) + XCTAssertEqual(l.nights.map(\.deltaMin), [120, -60]) + } + + func testImportedDebtIsVerbatimButDoesNotSeedFollowingFallback() { + let values = SleepDebt.debtSeries( + series: [("2026-06-01", 420), ("2026-06-02", 480)], + needHours: 8, + importedDebtMin: ["2026-06-01": 61.25]) + XCTAssertEqual(values[0].value, 61.25, accuracy: 1e-9) + XCTAssertEqual(values[1].value, 18.2, accuracy: 1e-9) + } + + func testDebtSeriesRoundsLocalOutputsToLedgerPrecision() { + let input: [(day: String, totalSleepMin: Double?)] = [ + ("2026-06-01", 420), ("2026-06-02", 410), + ] + let values = SleepDebt.debtSeries(series: input, needHours: 8) + let ledger = SleepDebt.ledger(series: input, needHours: 8) + XCTAssertEqual(values.last?.value ?? -1, 56.7, accuracy: 1e-9) + XCTAssertEqual(values.last?.value ?? -1, ledger.magnitudeMin, accuracy: 1e-9) + } + + func testImportedOnlyGapDoesNotConsumeOneOfFourteenUsableNights() { + let usable: [(day: String, totalSleepMin: Double?)] = (1...14).map { + (String(format: "2026-06-%02d", $0), 420) + } + let input = Array(usable.prefix(13)) + + [(day: "2026-06-14-imported-only", totalSleepMin: nil)] + + Array(usable.suffix(1)) + let values = SleepDebt.debtSeries( + series: input, + needHours: 8, + importedDebtMin: ["2026-06-14-imported-only": 91.25]) + + XCTAssertEqual(values.count, 15) + XCTAssertEqual(values[13].value, 91.25, accuracy: 1e-9) + XCTAssertEqual(values.last?.value ?? -1, + SleepDebt.ledger(series: usable, needHours: 8, window: 14).magnitudeMin, + accuracy: 1e-9) } - /// The symmetric POSITIVE exact half-tie (+0.05 → +0.1), pinned so the sign-aware Kotlin - /// rounding can't silently regress one direction. needMin = 0, slept 0.05 → delta +0.05. - func testPositiveHalfTieRoundsAwayFromZero() { - let l = SleepDebt.ledger(series: [("2026-06-01", 0.05)], needHours: 0.0) - XCTAssertEqual(l.balanceMin, 0.1, accuracy: 1e-9) + func testDebtSeriesKeepsFullHistoryWithPerDayTrailingLedgerParity() { + let input: [(day: String, totalSleepMin: Double?)] = (1...16).map { + (String(format: "2026-06-%02d", $0), 390 + Double($0)) + } + let values = SleepDebt.debtSeries(series: input, needHours: 8, window: 14) + + XCTAssertEqual(values.count, 16) + for index in values.indices { + let expected = SleepDebt.ledger( + series: Array(input.prefix(index + 1)), needHours: 8, window: 14).magnitudeMin + XCTAssertEqual(values[index].day, input[index].day) + XCTAssertEqual(values[index].value, expected, accuracy: 1e-9, + "per-day parity at \(values[index].day)") + } } /// round1 is exercised on the directly-constructed value too, so the rounding mode is diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepNightLabelTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepNightLabelTests.swift new file mode 100644 index 0000000000..41ffb46410 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepNightLabelTests.swift @@ -0,0 +1,94 @@ +import XCTest +@testable import StrandAnalytics + +/// Swift twin of `SleepHeroLogicTest`. The Kotlin side has had these since the logic was written; the +/// Swift side had none, because the function lived private inside the view — which is why the defect +/// below went uncaught on this platform until a report arrived. +final class SleepNightLabelTests: XCTestCase { + + private var utc: Calendar = { + var c = Calendar(identifier: .gregorian) + c.timeZone = TimeZone(identifier: "UTC")! + return c + }() + + /// A wake at `hour` on the given day, as an epoch second. + private func wake(_ y: Int, _ m: Int, _ d: Int, _ hour: Int = 7) -> Int { + var c = DateComponents() + c.year = y; c.month = m; c.day = d; c.hour = hour + return Int(utc.date(from: c)!.timeIntervalSince1970) + } + + private func day(_ y: Int, _ m: Int, _ d: Int, _ hour: Int = 12) -> Date { + var c = DateComponents() + c.year = y; c.month = m; c.day = d; c.hour = hour + return utc.date(from: c)! + } + + /// The bug. Measured from the newest RECORDED night, offset 0 was always zero, so the hero read + /// "Last night" over a night days old — beside the correct date, contradicting it. + func testAStaleNewestNightIsNotCalledLastNight() { + let n = SleepNightLabel.nightsAgo( + wakeTimestamps: [wake(2026, 9, 5)] as [Int?], offset: 0, + today: day(2026, 9, 7), calendar: utc) + XCTAssertEqual(n, 2) + } + + func testTheNightThatEndedThisMorningIsStillLastNight() { + let n = SleepNightLabel.nightsAgo( + wakeTimestamps: [wake(2026, 9, 7)] as [Int?], offset: 0, + today: day(2026, 9, 7), calendar: utc) + XCTAssertEqual(n, 0) + } + + /// Calendar distance, not carousel index: a night with no data is skipped by the carousel, so + /// labelling by index would make the nights either side of it read as consecutive. + func testCountsCalendarNightsNotCarouselIndex() { + let nights: [Int?] = [wake(2026, 8, 13), wake(2026, 8, 10)] + XCTAssertEqual(SleepNightLabel.nightsAgo(wakeTimestamps: nights, offset: 0, + today: day(2026, 8, 13), calendar: utc), 0) + XCTAssertEqual(SleepNightLabel.nightsAgo(wakeTimestamps: nights, offset: 1, + today: day(2026, 8, 13), calendar: utc), 3) + } + + /// Only TODAY is rolled; the shown night keeps its calendar wake-date, because that is what the + /// carousel groups by. Rolling both sides collapsed two distinct entries onto one label. + func testTwoNightsEitherSideOfTheRollKeepDistinctLabels() { + let nights: [Int?] = [wake(2026, 9, 7, 2), wake(2026, 9, 6, 7)] + XCTAssertEqual(SleepNightLabel.nightsAgo(wakeTimestamps: nights, offset: 0, + today: day(2026, 9, 7), calendar: utc), 0) + XCTAssertEqual(SleepNightLabel.nightsAgo(wakeTimestamps: nights, offset: 1, + today: day(2026, 9, 7), calendar: utc), 1) + } + + /// The pre-roll window, which lands on the NEGATIVE branch and gets the right answer from what + /// reads like an error fallback. Wake at 02:00, check at 03:00: the night's calendar date is the + /// 7th while the logical day is still the 6th, so the distance is -1 and offset 0 is genuinely + /// "Last night". Pinned so a later tightening of that branch cannot break it silently. + func testANightWokenBeforeTheRollStillReadsLastNight() { + let n = SleepNightLabel.nightsAgo( + wakeTimestamps: [wake(2026, 9, 7, 2)] as [Int?], offset: 0, + today: day(2026, 9, 6, 23), calendar: utc) + XCTAssertEqual(n, 0) + } + + func testOutOfRangeFallsBackToTheIndex() { + XCTAssertEqual(SleepNightLabel.nightsAgo(wakeTimestamps: [] as [Int?], offset: 5, + today: day(2026, 9, 7), calendar: utc), 5) + } + + /// A carousel entry with no session falls back to the offset, exactly as the Kotlin twin does. + /// Mapping it to 0 would put the night in 1970 and print roughly twenty thousand nights ago. + /// + /// The dates are chosen so the fallback is DISTINGUISHABLE: at offset 1 the answer is 1 only + /// because the guard fired, since the real night sits fourteen nights back and an unguarded 1970 + /// would be five figures. A nil at offset 0 against a same-day today would have returned 0 either + /// way and proved nothing. + func testAnEntryWithNoSessionFallsBackToTheIndex() { + let nights: [Int?] = [wake(2026, 9, 6), nil] + XCTAssertEqual(SleepNightLabel.nightsAgo(wakeTimestamps: nights, offset: 0, + today: day(2026, 9, 20), calendar: utc), 14) + XCTAssertEqual(SleepNightLabel.nightsAgo(wakeTimestamps: nights, offset: 1, + today: day(2026, 9, 20), calendar: utc), 1) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerActiveBridgeTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerActiveBridgeTests.swift new file mode 100644 index 0000000000..ac82c1831d --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerActiveBridgeTests.swift @@ -0,0 +1,256 @@ +import XCTest +@testable import StrandAnalytics +import WhoopProtocol + +/// #1657 twin of `SleepStagerActiveBridgeTest`. The sparse-gravity bridge could only ever join sleep +/// runs already adjacent in its own output, so ANY active run between two sleep runs blocked the merge +/// permanently. A field trace found it merging nothing on 14 of 14 sparse nights for exactly that reason +/// — and since a bathroom trip is definitionally an active run, the rescue built for fragmentation was +/// unavailable in the case that needs it most. The pieces then died at the 60-minute session floor, +/// which is how a 6h40m night scored 150 minutes. +final class SleepStagerActiveBridgeTests: XCTestCase { + + private func sleep(_ start: Int, _ end: Int) -> SleepStager.Period { + SleepStager.Period(stage: "sleep", start: start, end: end) + } + private func active(_ start: Int, _ end: Int) -> SleepStager.Period { + SleepStager.Period(stage: "active", start: start, end: end) + } + /// Flat HR well under the band, so the HR gate is never the thing under test. + private func calmHr(_ from: Int, _ to: Int, bpm: Int = 50) -> [HRSample] { + stride(from: from, through: to, by: 60).map { HRSample(ts: $0, bpm: bpm) } + } + private let baseline = 60.0 + + /// THE reported shape: asleep, a short trip, asleep again. Each piece is under the 60-minute session + /// floor on its own; together they are a night. + func testAShortActiveInterruptionBetweenTwoSleepRunsIsAbsorbed() { + let out = SleepStager.bridgeSparseSleep( + [sleep(0, 3000), active(3000, 3900), sleep(3900, 9000)], + sparse: true, hr: calmHr(0, 9000), baseline: baseline) + XCTAssertEqual(out.count, 1) + XCTAssertEqual(out.first?.stage, "sleep") + XCTAssertEqual(out.first?.start, 0) + XCTAssertEqual(out.first?.end, 9000) + } + + /// The guard that keeps this honest. A long active run is a real break in the night, not a stir, and + /// absorbing it would score wakefulness as sleep — wrong in a new direction and harder to notice than + /// the truncation being fixed. + func testAnActiveRunLongerThanTheBoundIsLeftAlone() { + // Repinned past the IN-BAND bound: this HR is calm, so that is the bound in force. + let tooLong = SleepStager.sparseBridgeActiveMaxInBandMin * 60 + 60 + let out = SleepStager.bridgeSparseSleep( + [sleep(0, 3000), active(3000, 3000 + tooLong), sleep(3000 + tooLong, 9000)], + sparse: true, hr: calmHr(0, 9000), baseline: baseline) + XCTAssertEqual(out.count, 3) + } + + /// HR is the real gate, not the duration bound. A wearer who is genuinely up keeps HR elevated for + /// the whole interruption, and that must still block the merge even when it is short. + func testAShortInterruptionWithElevatedHRIsNotAbsorbed() { + let hot = calmHr(0, 3000) + calmHr(3001, 3900, bpm: 110) + calmHr(3901, 9000) + let out = SleepStager.bridgeSparseSleep( + [sleep(0, 3000), active(3000, 3900), sleep(3900, 9000)], + sparse: true, hr: hot, baseline: baseline) + XCTAssertEqual(out.count, 3) + } + + /// Two consecutive active runs are a night with structure in it, not one interruption. + func testTwoConsecutiveActiveRunsAreNotAbsorbed() { + let out = SleepStager.bridgeSparseSleep( + [sleep(0, 3000), active(3000, 3300), active(3300, 3900), sleep(3900, 9000)], + sparse: true, hr: calmHr(0, 9000), baseline: baseline) + XCTAssertEqual(out.count, 4) + } + + /// A dense 4.0 night must be byte-identical: the bridge is sparse-only and always has been. + /// `Period` is not Equatable in production, so the fields are compared rather than widening a + /// production type for a test's convenience. + func testADenseNightIsUntouched() { + let periods = [sleep(0, 3000), active(3000, 3900), sleep(3900, 9000)] + let out = SleepStager.bridgeSparseSleep(periods, sparse: false, hr: calmHr(0, 9000), + baseline: baseline) + XCTAssertEqual(out.map(\.stage), periods.map(\.stage)) + XCTAssertEqual(out.map(\.start), periods.map(\.start)) + XCTAssertEqual(out.map(\.end), periods.map(\.end)) + } + + /// The pre-existing behaviour — a bare gap between two sleep runs — still merges. + func testTheOriginalAdjacentPairMergeStillWorks() { + let out = SleepStager.bridgeSparseSleep( + [sleep(0, 3000), sleep(3600, 9000)], + sparse: true, hr: calmHr(0, 9000), baseline: baseline) + XCTAssertEqual(out.count, 1) + XCTAssertEqual(out.first?.end, 9000) + } + + /// The trace has to say WHY, and the blocking length is the number a reader needs. The old trace could + /// only report runsBefore == runsAfter, which says the bridge did nothing and not what stopped it. + func testABlockedPairReportsTheBoundThatBlockedItWithTheActiveLength() { + // Repinned past the IN-BAND bound: this HR is calm, so that is the bound in force. + let tooLong = SleepStager.sparseBridgeActiveMaxInBandMin * 60 + 60 + let (_, attempts) = SleepStager.bridgeSparseSleepTraced( + [sleep(0, 3000), active(3000, 3000 + tooLong), sleep(3000 + tooLong, 9000)], + sparse: true, hr: calmHr(0, 9000), baseline: baseline) + XCTAssertEqual(attempts.count, 1) + XCTAssertEqual(attempts.first?.reason, "activeTooLong") + XCTAssertEqual(attempts.first?.bridged, false) + XCTAssertEqual(attempts.first?.activeMin, SleepStager.sparseBridgeActiveMaxInBandMin + 1) + // And it names WHICH bound, or the same activeMin reads blocked in one log, merged in another. + XCTAssertEqual(attempts.first?.activeCapMin, SleepStager.sparseBridgeActiveMaxInBandMin) + } + + /// The tracer and the merge are ONE pass now. This file used to keep a shadow copy of the loop purely + /// to trace it, which had to be edited in step with the real one — a trace that quietly disagrees + /// with the behaviour it describes is worse than no trace at all. + func testTheTracedPassReturnsExactlyWhatThePlainOneDoes() { + let periods = [sleep(0, 3000), active(3000, 3900), sleep(3900, 9000)] + let hr = calmHr(0, 9000) + let plain = SleepStager.bridgeSparseSleep(periods, sparse: true, hr: hr, baseline: baseline) + let traced = SleepStager.bridgeSparseSleepTraced(periods, sparse: true, hr: hr, + baseline: baseline).0 + XCTAssertEqual(plain.map(\.stage), traced.map(\.stage)) + XCTAssertEqual(plain.map(\.start), traced.map(\.start)) + XCTAssertEqual(plain.map(\.end), traced.map(\.end)) + } + + /// #1657, the other half: `hrSleepBandAcross` judged on the MEAN, which a single arousal spike drags + /// out of band — the exact statistic `confirmSleepWithHR` documents as wrong for this, and uses the + /// median for instead. A sustained elevation must still be rejected. + func testABriefSpikeNoLongerPutsTheWholeIntervalOutOfBandButASustainedOneDoes() { + let spiky = calmHr(0, 3540) + calmHr(3541, 3660, bpm: 190) + XCTAssertTrue(SleepStager.hrSleepBandAcross(0, 3660, hr: spiky, baseline: baseline)) + XCTAssertFalse(SleepStager.hrSleepBandAcross(0, 3660, hr: calmHr(0, 3660, bpm: 110), + baseline: baseline)) + } + + // MARK: - End to end, through detectSleep + // + // The unit cases above pin the bridge. These pin the thing the reporter actually saw: a night with + // one interruption arriving as ONE session instead of a truncated fragment. + // + // This twin is not optional. The Kotlin version of exactly this test is what found the bound wrong — + // every unit case passed at 20 while the real pipeline still returned two sessions, because + // classifyStill smears the still/moving boundary and buildRuns closes at sample edges, so a 15-minute + // interruption becomes a 21-minute run. That smear is a property of THIS implementation, so without + // the twin a divergence in Swift's windowing would leave the bound silently wrong on Apple only. + + /// 2025-06-10 00:00:00 UTC. + private var refMidnight: Int { 1_749_513_600 } + private func at(_ hour: Int, _ minute: Int = 0) -> Int { refMidnight + hour * 3_600 + minute * 60 } + + /// Still gravity at 1/min — constant orientation, so every delta is 0. + private func stillG(_ from: Int, _ toExclusive: Int) -> [GravitySample] { + stride(from: from, to: toExclusive, by: 60).map { GravitySample(ts: $0, x: 0, y: 0, z: 1.0) } + } + + /// Moving gravity at 1/min — orientation swings every sample, so deltas are large. + private func movingG(_ from: Int, _ toExclusive: Int) -> [GravitySample] { + stride(from: from, to: toExclusive, by: 60).enumerated().map { i, t in + i % 2 == 0 ? GravitySample(ts: t, x: 1.0, y: 0, z: 0) + : GravitySample(ts: t, x: 0, y: 1.0, z: 0) + } + } + + private func hr1Hz(_ from: Int, _ toExclusive: Int, _ bpm: Int) -> [HRSample] { + (from ..< toExclusive).map { HRSample(ts: $0, bpm: bpm) } + } + + /// A night with a 30-minute gravity dropout early (so it reads sparse, as a 5/MG's does) and a + /// 15-minute up-and-about in the middle. + private func interruptedNight(tripBpm: Int) -> ([HRSample], [GravitySample]) { + let grav = stillG(at(0), at(0, 30)) + + stillG(at(1), at(2)) + + movingG(at(2), at(2, 15)) + + stillG(at(2, 15), at(6)) + let hr = hr1Hz(at(0), at(2), 50) + + hr1Hz(at(2), at(2, 15), tripBpm) + + hr1Hz(at(2, 15), at(6), 50) + return (hr, grav) + } + + func testANightWithOneShortInterruptionIsDetectedAsASingleSession() { + let (hr, grav) = interruptedNight(tripBpm: 52) + XCTAssertTrue(SleepStager.isGravitySparse(grav, hr: hr), "the fixture must read as sparse") + let sessions = SleepStager.detectSleep(hr: hr, gravity: grav) + XCTAssertEqual(sessions.count, 1, "the interruption must not end the night") + let spanMin = Double((sessions[0].end - sessions[0].start)) / 60.0 + XCTAssertGreaterThan(spanMin, 5 * 60, "the whole night should survive, got \(spanMin) min") + } + + /// The same night with the wearer genuinely up — HR elevated for the whole quarter hour. Absorbing + /// that would score wakefulness as sleep: wrong in a new direction and harder to notice than the + /// truncation being fixed. + func testTheSameNightWithAGenuinelyAwakeInterruptionIsNotBridgedIntoOne() { + let (hr, grav) = interruptedNight(tripBpm: 110) + let sessions = SleepStager.detectSleep(hr: hr, gravity: grav) + let spanMin = sessions.reduce(0.0) { $0 + Double($1.end - $1.start) / 60.0 } + XCTAssertTrue(sessions.count != 1 || spanMin < 5 * 60, + "an awake interruption must not be absorbed (\(sessions.count) sessions, \(spanMin) min)") + } + + /// The RENDERED line, not just the fields — and deliberately the whole string, byte for byte. + /// + /// The Kotlin twin asserts this identical literal. Nothing in the tree compares the two languages' + /// trace output automatically, so a format change on one side would otherwise diverge in silence and + /// only surface when someone tried to read a capture from the other platform. + func testThePairLineRendersExactlyAsItsKotlinTwinDoes() { + let line = SleepStager.GateTrace.runLine( + index: -1, startTs: 0, endTs: 0, verdict: SleepStager.GateVerdict.dropped, gate: "sparseBridgePair", + detail: "pair=0 gapMin=23 activeMin=21 hrInSleepBand=true reason=activeTooLong") + XCTAssertEqual( + line, + "gate run=-1 spanS=0 DROPPED gate=sparseBridgePair " + + "pair=0 gapMin=23 activeMin=21 hrInSleepBand=true reason=activeTooLong") + } + + /// The change this constant exists for. A 45-minute interruption sits between the two bounds: over + /// the 30-minute default, under the 60-minute in-band one. With HR in the sleep band the whole way it + /// is now absorbed, where before the minute bound vetoed before HR was ever consulted. + /// + /// Field shape, not invented: log 260901-1022 had a 42-minute active run with hrInSleepBand=true + /// dropped as activeTooLong, and the 52-minute sleep run it would have joined then died on the + /// 60-minute minimum. Kotlin twin: "an active run past the default bound is absorbed when HR stays + /// in the sleep band". + func testActiveRunPastTheDefaultBoundIsAbsorbedWhenHRStaysInTheSleepBand() { + let mid = SleepStager.sparseBridgeActiveMaxMin * 60 + 15 * 60 // 45 min + let periods = [sleep(0, 3000), active(3000, 3000 + mid), sleep(3000 + mid, 9000)] + let out = SleepStager.bridgeSparseSleep(periods, sparse: true, hr: calmHr(0, 9000), baseline: baseline) + XCTAssertEqual(out.count, 1) + XCTAssertEqual(out.first?.stage, "sleep") + XCTAssertEqual(out.first?.start, 0) + XCTAssertEqual(out.first?.end, 9000) + } + + /// ...and the widened bound is not a licence. The same 45 minutes with the wearer plainly up still + /// fails, because in-band is the CONDITION for the wider bound, not a separate escape from it. + func testTheWiderBoundDoesNotApplyWhenHRIsElevated() { + let mid = SleepStager.sparseBridgeActiveMaxMin * 60 + 15 * 60 + let periods = [sleep(0, 3000), active(3000, 3000 + mid), sleep(3000 + mid, 9000)] + let hot = calmHr(0, 3000) + calmHr(3001, 3000 + mid, bpm: 110) + calmHr(3001 + mid, 9000) + let out = SleepStager.bridgeSparseSleep(periods, sparse: true, hr: hot, baseline: baseline) + XCTAssertEqual(out.count, 3) + } + + /// The in-band bound may only ever widen, and it stops at `minSleepMin`. + func testTheInBandBoundIsAMaximumNeverAReplacement() { + XCTAssertGreaterThanOrEqual(SleepStager.sparseBridgeActiveMaxInBandMin, + SleepStager.sparseBridgeActiveMaxMin) + XCTAssertEqual(SleepStager.sparseBridgeActiveMaxInBandMin, SleepStager.minSleepMin) + } + + /// Case 1 (two adjacent sleep runs, nothing between) has NO active run, so the bound it reports must + /// be 0 on both platforms — not the in-band 60. Computing the cap from a captured local rather than a + /// parameter made this report 60 for the identical decision: same behaviour, divergent trace. + /// Kotlin twin: "an adjacent-pair merge reports no active bound". + func testAnAdjacentPairMergeReportsNoActiveBound() { + let (_, attempts) = SleepStager.bridgeSparseSleepTraced( + [sleep(0, 3000), sleep(3600, 9000)], + sparse: true, hr: calmHr(0, 9000), baseline: baseline) + XCTAssertEqual(attempts.count, 1) + XCTAssertEqual(attempts.first?.reason, "bridged") + XCTAssertEqual(attempts.first?.activeMin, 0) + XCTAssertEqual(attempts.first?.activeCapMin, 0) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerHrOnlyAnchorTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerHrOnlyAnchorTests.swift new file mode 100644 index 0000000000..68731afed1 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerHrOnlyAnchorTests.swift @@ -0,0 +1,53 @@ +import XCTest +import WhoopProtocol +@testable import StrandAnalytics + +/// Twin of the Kotlin `SleepStagerHrOnlyAnchorTest`: what anchors the HR-only band, and why it is not +/// the median (#1801). +final class SleepStagerHrOnlyAnchorTests: XCTestCase { + + private func window(_ aC: Int, _ aA: Int, _ aH: Int, _ nC: Int, _ nA: Int, _ nH: Int) -> [HRSample] { + let t0 = 1_788_300_000 + var hr: [HRSample] = [] + for i in 0..<(aH * 3600) { hr.append(HRSample(ts: t0 + i, bpm: aC + Int(sin(Double(i) / 500.0) * Double(aA)))) } + for j in 0..<(nH * 3600) { + hr.append(HRSample(ts: t0 + aH * 3600 + j, bpm: nC + Int(sin(Double(j) / 900.0) * Double(nA)))) + } + return hr + } + + private func sleepHours(_ hr: [HRSample], _ baseline: Double) -> Double { + Double(SleepStager.hrOnlySleepRuns(hr, baseline: baseline) + .filter { $0.stage == "sleep" } + .reduce(0) { $0 + ($1.end - $1.start) }) / 3600.0 + } + + func testAnchorIsTenthPercentileNearestRank() { + let hr = (1...100).map { HRSample(ts: 1_788_300_000 + $0, bpm: $0) } + XCTAssertEqual(SleepStager.hrOnlyAnchorPercentile, 0.10, accuracy: 1e-9) + XCTAssertEqual(SleepStager.hrOnlyBaseline(hr)!, 10.0, accuracy: 1e-9) + } + + /// The regression this file exists for: the median anchor admits over half of any window by + /// definition, and measured 14.4 h against a truth of 8 on a field-shaped day. + func testMedianOverDetectsAndPercentileDoesNot() { + let hr = window(74, 11, 16, 64, 5, 8) + let median = sleepHours(hr, SleepStager.hrBaseline(hr)!) + let percentile = sleepHours(hr, SleepStager.hrOnlyBaseline(hr)!) + XCTAssertGreaterThan(median, 13.0) + XCTAssertTrue((7.0...9.5).contains(percentile), "was \(percentile)") + XCTAssertLessThan(percentile, median - 4.0) + } + + /// Errs toward UNDER-detection: a missed night leaves "No data", an invented one shows a wrong Rest. + func testLongMultiNightWindowIsUnderReadNotOverRead() { + let h = sleepHours(window(78, 12, 38, 60, 6, 16), SleepStager.hrOnlyBaseline(window(78, 12, 38, 60, 6, 16))!) + XCTAssertLessThanOrEqual(h, 16.0, "was \(h)") + XCTAssertGreaterThanOrEqual(h, 6.0, "was \(h)") + } + + func testDetectorBandIsASeparateConstantFromTheConfirmationGate() { + XCTAssertEqual(SleepStager.hrOnlyBandMult, 1.05, accuracy: 1e-9) + XCTAssertEqual(SleepStager.hrSleepBandMult, 1.05, accuracy: 1e-9) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerHrOnlySessionsTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerHrOnlySessionsTests.swift new file mode 100644 index 0000000000..df2182062e --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerHrOnlySessionsTests.swift @@ -0,0 +1,81 @@ +import Foundation +import XCTest +import WhoopProtocol +@testable import StrandAnalytics + +/// Whole HR-only sessions (#1801) and the physiology they report (#1884). +/// +/// The ORACLE twin of the Kotlin `SleepStagerHrOnlySessionsTest`: the same synthetic night and the same +/// expectations, so a divergence in the null contract fails on one platform and not the other. The Apple +/// side had no twin of this file until #1884, which is exactly how the two `restingHR`/`avgHRV` spellings +/// could have drifted apart unnoticed — Swift structs have no `copy()`, so each rebuild respells the +/// field list and a field is lost by omission rather than by an edit anyone reviews. +/// +/// The night is synthetic: a slow HR drift well under the window median, so the spine puts it in the +/// sleep band. That exercises assembly and the reporting contract; it is NOT a claim that the staging is +/// accurate on a real night, which no test here can establish. +final class SleepStagerHrOnlySessionsTests: XCTestCase { + + /// A field-shaped window: `aH` hours awake around 74 +/- 11 bpm, then `nH` hours asleep around + /// 64 +/- 5 — the shape the #1801 report shows, and the same generator the Kotlin twin uses. + private func window(aH: Int = 16, nH: Int = 8) -> ([HRSample], [RRInterval]) { + let t0 = 1_788_300_000 + var hr: [HRSample] = [] + var rr: [RRInterval] = [] + for i in 0..<(aH * 3600) { + let bpm = 74 + Int(sin(Double(i) / 500.0) * 11) + hr.append(HRSample(ts: t0 + i, bpm: bpm)) + rr.append(RRInterval(ts: t0 + i, rrMs: 60000 / bpm)) + } + for j in 0..<(nH * 3600) { + let bpm = 64 + Int(sin(Double(j) / 900.0) * 5) + let t = t0 + aH * 3600 + j + hr.append(HRSample(ts: t, bpm: bpm)) + rr.append(RRInterval(ts: t, rrMs: 60000 / bpm)) + } + return (hr, rr) + } + + func testLowHRNightBecomesAtLeastOneStagedSession() { + let (hr, rr) = window() + let out = SleepStager.hrOnlySessions(hr: hr, rr: rr, resp: []) + XCTAssertFalse(out.isEmpty, "expected at least one night, got \(out.count)") + XCTAssertTrue(out.allSatisfy { !$0.stages.isEmpty }, "every session must carry stages") + XCTAssertTrue(out.allSatisfy { ($0.end - $0.start) >= SleepStager.minSleepMin * 60 }, + "every session must span at least minSleepMin") + // Conservative by design: it may under-read a night, but must never invent more sleep than the + // window holds. + XCTAssertLessThanOrEqual(out.reduce(0) { $0 + ($1.end - $1.start) }, 8 * 3600, + "total must not exceed the 8 h actually asleep") + } + + /// #1884 reversed the null contract #1801 established, so the reasoning is worth keeping beside it. + /// + /// #1801 withheld `restingHR` and `avgHRV` here on the grounds that a baseline is the one thing a + /// false positive cannot be unwound from. The field logs showed the withholding was the more damaging + /// error: the values are MEASURED, not inferred. The BOUNDS are what heart rate infers — which is why + /// the session still marks itself `hrOnly` — but each RMSSD is computed over its own 5-minute window, + /// so fuzzy bounds change WHICH windows are included, not whether any one of them is valid. Resting HR + /// is HR-derived, and an HR-only night is precisely the night with plenty of HR. + /// + /// The marker travels with the session, so a consumer that wants to weigh these down still can. + func testHrOnlySessionReportsMeasuredRestingHRAndHRVAndStillMarksItself() throws { + let (hr, rr) = window() + let s = try XCTUnwrap(SleepStager.hrOnlySessions(hr: hr, rr: rr, resp: []).first) + XCTAssertTrue(s.hrOnly, "must still be flagged hrOnly") + XCTAssertNotNil(s.restingHR, "restingHR is HR-derived and must be reported") + XCTAssertNotNil(s.avgHRV, "avgHRV must be reported when R-R is present") + } + + /// The honest boundary of the change: reporting is driven by whether the INPUT exists, not by the + /// `hrOnly` flag. With no R-R there is nothing to compute an RMSSD from, so HRV is still absent — and + /// resting HR, which needs only HR, is still reported. A regression that re-blanked HRV wholesale + /// would pass the case above if it also happened to blank on missing R-R; this separates them. + func testHrOnlySessionWithoutRRStillReportsRestingHR() throws { + let (hr, _) = window() + let s = try XCTUnwrap(SleepStager.hrOnlySessions(hr: hr, rr: [], resp: []).first) + XCTAssertTrue(s.hrOnly, "must still be flagged hrOnly") + XCTAssertNotNil(s.restingHR, "restingHR needs only HR") + XCTAssertNil(s.avgHRV, "no R-R means no RMSSD to report") + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerHrOnlySpineTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerHrOnlySpineTests.swift new file mode 100644 index 0000000000..5278d38cdf --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerHrOnlySpineTests.swift @@ -0,0 +1,81 @@ +import XCTest +import WhoopProtocol +@testable import StrandAnalytics + +/// The HR-only Stage-0 spine (#1801), for a strap that streams heart rate but banks no motion. +/// +/// These cases are the ORACLE for the Kotlin twin `SleepStagerHrOnlySpineTest`: the same inputs and the +/// same expected runs are asserted on both sides, so a divergence in the flag rule, the epoch bucketing +/// or the run construction fails on one platform and not the other. +/// +/// Baseline 60 throughout, so the sleep band is `60 * hrSleepBandMult` = 63.0 bpm inclusive. Samples are +/// laid down every 10 s, six to a 60 s epoch. +final class SleepStagerHrOnlySpineTests: XCTestCase { + + /// Six samples in each epoch, `bpm` per epoch index. + private func hr(from epoch: Int, _ bpms: [Int]) -> [HRSample] { + var out: [HRSample] = [] + for (i, bpm) in bpms.enumerated() { + let base = (epoch + i) * 60 + for k in 0..<6 { out.append(HRSample(ts: base + k * 10, bpm: bpm)) } + } + return out + } + + private func tuples(_ p: [SleepStager.Period]) -> [String] { + p.map { "\($0.stage) \($0.start)-\($0.end)" } + } + + func testNoBaselineOrNoHrYieldsNothing() { + XCTAssertTrue(SleepStager.hrOnlySleepRuns(hr(from: 1000, [55]), baseline: nil).isEmpty) + XCTAssertTrue(SleepStager.hrOnlySleepRuns(hr(from: 1000, [55]), baseline: 0).isEmpty) + XCTAssertTrue(SleepStager.hrOnlySleepRuns([], baseline: 60).isEmpty) + } + + func testAllInBandIsOneSleepRun() { + let runs = SleepStager.hrOnlySleepRuns(hr(from: 1000, [55, 55, 55]), baseline: 60) + XCTAssertEqual(tuples(runs), ["sleep 60000-60170"]) + } + + func testAllOutOfBandIsOneActiveRun() { + let runs = SleepStager.hrOnlySleepRuns(hr(from: 1000, [90, 90, 90]), baseline: 60) + XCTAssertEqual(tuples(runs), ["active 60000-60170"]) + } + + func testSleepActiveSleepSegmentsIntoThree() { + let runs = SleepStager.hrOnlySleepRuns(hr(from: 1000, [55, 55, 90, 55, 55]), baseline: 60) + XCTAssertEqual(tuples(runs), ["sleep 60000-60110", "active 60120-60170", "sleep 60180-60290"]) + } + + /// A gap wider than `maxGapMin` breaks a run even when the class never changes — the same rule + /// `buildRuns` applies to a gravity gap. + func testGapWiderThanMaxGapBreaksASameClassRun() { + let runs = SleepStager.hrOnlySleepRuns(hr(from: 1000, [55]) + hr(from: 1030, [55]), baseline: 60) + XCTAssertEqual(tuples(runs), ["sleep 60000-60050", "sleep 61800-61850"]) + } + + /// The band test is a MEDIAN, so one arousal spike inside an epoch cannot flip it to active — the + /// property `hrSleepBandAcross` documents, asserted here on the epoch reduction. + func testOneSpikeInAnEpochDoesNotFlipIt() { + var samples = hr(from: 1000, [55]) + samples[5] = HRSample(ts: samples[5].ts, bpm: 190) + XCTAssertEqual(tuples(SleepStager.hrOnlySleepRuns(samples, baseline: 60)), ["sleep 60000-60050"]) + } + + /// A run's `end` is the last SAMPLE seen, not the final epoch's start. Pinned because reading the + /// epoch start reports every run one epoch short of the data it covers — silently, and straight into + /// the caller's minimum-duration gate. Three 60 s epochs of samples laid every 10 s end at 60170. + func testRunEndIsTheLastSampleNotTheEpochStart() { + let runs = SleepStager.hrOnlySleepRuns(hr(from: 1000, [55, 55, 55]), baseline: 60) + XCTAssertEqual(runs.first?.end, 60170) + XCTAssertEqual((runs.first!.end - runs.first!.start), 170) + } + + /// The band is inclusive: 63 is `60 * 1.05` exactly. + func testBandBoundaryIsInclusive() { + XCTAssertEqual(tuples(SleepStager.hrOnlySleepRuns(hr(from: 1000, [63]), baseline: 60)), + ["sleep 60000-60050"]) + XCTAssertEqual(tuples(SleepStager.hrOnlySleepRuns(hr(from: 1000, [64]), baseline: 60)), + ["active 60000-60050"]) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerHrOnlyTraceTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerHrOnlyTraceTests.swift new file mode 100644 index 0000000000..a219d301b8 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerHrOnlyTraceTests.swift @@ -0,0 +1,57 @@ +import XCTest +import WhoopProtocol +@testable import StrandAnalytics + +/// Twin of the Kotlin `SleepStagerHrOnlyTraceTest`. The expected strings are byte-identical on both +/// platforms on purpose — these lines exist to be read side by side. +final class SleepStagerHrOnlyTraceTests: XCTestCase { + + func testLineNamesTheDerivedThresholdAndLongestCandidate() { + XCTAssertEqual( + SleepStager.GateTrace.hrOnlyLine(anchorBpm: 61.0, bandBpm: 64.05, hrP50: 74.0, hrP90: 88.0, epochs: 3021, runs: 48, + mergedRuns: 12, sleepRuns: 7, longestSleepMin: 41, + staged: 0, kept: 0, minSleepMin: 60), + "[sleep] hr-only spine anchorBpm=61.0 bandBpm=64.1 hrP50=74.0 hrP90=88.0 " + + "epochs=3021 runs=48 merged=12 " + + "sleepRuns=7 longestMin=41 staged=0 kept=0 minSleepMin=60" + ) + } + + func testAbsentAnchorPrintsNilRatherThanZero() { + let line = SleepStager.GateTrace.hrOnlyLine(anchorBpm: nil, bandBpm: nil, hrP50: nil, hrP90: nil, epochs: 0, runs: 0, + mergedRuns: 0, sleepRuns: 0, longestSleepMin: 0, + staged: 0, kept: 0, minSleepMin: 60) + XCTAssertTrue(line.contains("anchorBpm=nil bandBpm=nil")) + } + + /// `epochs` counts EPOCHS, not samples — the axis every other number is measured on. Twin of the + /// Kotlin test, which guards a bug I actually made: the first version reported the HR sample count. + func testEpochsCountsEpochsNotSamples() { + var lines: [String] = [] + // 10 epochs x 6 samples = 60 samples. + var hr: [HRSample] = [] + for i in 0..<10 { + let base = (1000 + i) * 60 + for k in 0..<6 { hr.append(HRSample(ts: base + k * 10, bpm: 120)) } + } + _ = SleepStager.hrOnlySessions(hr: hr, rr: [], resp: [], traceSink: { lines.append($0) }) + XCTAssertEqual(lines.count, 1, "exactly one funnel line per call") + XCTAssertTrue(lines[0].contains("epochs=10 "), "got: \(lines[0])") + } + + /// The rounding is ARITHMETIC, not `printf`. A harness caught `String(format: "%.1f", 64.05)` + /// giving 64.0 on Apple against Java's 64.1 — a divergence that `anchor * 1.05` would have hit + /// constantly. These are the values that harness compared. + func testOneDecimalRoundingMatchesTheKotlinTwin() { + let cases: [(Double, String)] = [ + (64.05, "64.1"), (61.0, "61.0"), (77.7, "77.7"), (66.15, "66.2"), + (71.4, "71.4"), (1.05, "1.1"), (0.0, "0.0"), (120.0, "120.0"), + ] + for (v, expected) in cases { + let line = SleepStager.GateTrace.hrOnlyLine(anchorBpm: v, bandBpm: nil, hrP50: nil, hrP90: nil, epochs: 0, runs: 0, + mergedRuns: 0, sleepRuns: 0, longestSleepMin: 0, + staged: 0, kept: 0, minSleepMin: 60) + XCTAssertTrue(line.contains("anchorBpm=\(expected) "), "\(v) -> expected \(expected), got: \(line)") + } + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTests.swift index 6ea4bcff3f..aac9f9147c 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTests.swift @@ -859,6 +859,163 @@ final class SleepStagerTests: XCTestCase { XCTAssertLessThan(hrv!, 50, "ectopic spikes must be rejected before rMSSD") } + // MARK: - Session window endpoint: the closed [start, end] window has a closed FINAL bin + + // `sessionRestingHR` and `sessionHrvWindows` prefilter ts ∈ [start, end], so a sample sitting + // exactly ON `end` is admitted. Binned as [t, t + 300) with a loop that stops at `end`, an + // ALIGNED end (end - start a multiple of the window) left that admitted sample in no bin: it + // counted as data and was then silently ignored. The final bin is therefore closed on `end`, + // the same rule RecoveryScorer already carries. Each case is paired with a non-aligned twin + // that was always correct, so the fix cannot be mistaken for a change to trailing partial bins. + // Expected values are the verbatim stdout of a standalone Swift oracle over the fixed binning; + // SleepStagerWindowEndpointTest.kt pins the same scenarios with the same literals. + + func testSessionRestingHRAlignedEndpointSampleReachesABin() { + // Minimal case: start = 0, end = 300 (one window wide), five samples on the endpoint. + // Already returned 60 before the fix, but only via the all-sample fallback (no bin held + // anything); it now flows through the bin path. + let hr = (0..<5).map { _ in HRSample(ts: 300, bpm: 60) } + XCTAssertEqual(SleepStager.sessionRestingHR(start: 0, end: 300, hr: hr), 60, + "a sample exactly on an aligned end must belong to the final bin") + } + + func testSessionRestingHRNonAlignedEndpointSampleReachesABin() { + // Paired non-aligned case: end = 450 falls mid-window, so the endpoint sample was always + // inside the trailing partial bin. Same answer, before and after. + let hr = (0..<5).map { _ in HRSample(ts: 450, bpm: 60) } + XCTAssertEqual(SleepStager.sessionRestingHR(start: 0, end: 450, hr: hr), 60, + "a sample on a non-aligned end stays in the trailing partial bin") + } + + func testSessionRestingHREndpointSampleLandsInFinalBinOnly() { + // Where the fallback cannot hide the dropped sample: a dense 70 bpm first bin plus five + // endpoint beats at 40 makes the floor 40, and the 70 bpm bin stays untouched. Before the + // fix the endpoint beats vanished and the shipped resting HR read 70. + var hr: [HRSample] = (0..<300).map { HRSample(ts: $0, bpm: 70) } + hr.append(contentsOf: (0..<5).map { _ in HRSample(ts: 600, bpm: 40) }) + XCTAssertEqual(SleepStager.sessionRestingHR(start: 0, end: 600, hr: hr), 40, + "the endpoint sample belongs to the final bin only") + } + + func testSessionRestingHRDegenerateZeroLengthWindow() { + // start == end: the single instant is one closed bin — the same number the all-sample + // fallback produced before. + let hr = (0..<5).map { _ in HRSample(ts: 1000, bpm: 58) } + XCTAssertEqual(SleepStager.sessionRestingHR(start: 1000, end: 1000, hr: hr), 58) + } + + func testSessionRestingHREndpointSweepMatchesOracle() { + // `end` walks a whole window width and beyond in 25 s steps against a dense 70 bpm opening + // bin plus five endpoint beats at 60. Verbatim oracle stdout, the check that catches drift + // the eye does not — including end = 300, where the single closed bin holds BOTH groups. + let expected = [62, 68, 69, 69, 70, 70, 70, 70, 70, 70, 70, 70, 70, + 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, + 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60] + var i = 0 + var endTs = 0 + while endTs <= 900 { + var hr: [HRSample] = (0..<300).map { HRSample(ts: $0, bpm: 70) } + hr.append(contentsOf: (0..<5).map { _ in HRSample(ts: endTs, bpm: 60) }) + XCTAssertEqual(SleepStager.sessionRestingHR(start: 0, end: endTs, hr: hr), expected[i], + "sessionRestingHR.end=\(endTs)") + i += 1 + endTs += 25 + } + } + + // MARK: - #1943: sessionRestingHR bin-population + plausibility gates + + // A one-sample bin at a dropout bpm would have won the floor before the gate. The gate excludes + // it (winnerN < minBinSamples), and the floor falls back to the next-best qualifying bin. + func testSessionRestingHRThinBinCannotWinTheFloor() { + // 300 samples at 60 in the first bin (qualifies), one sample at 38 in the final bin (thin). + // Before #1943 the floor was 38; now the thin bin is excluded and the floor is 60. + var hr: [HRSample] = (0..<300).map { HRSample(ts: $0, bpm: 60) } + hr.append(HRSample(ts: 600, bpm: 38)) + XCTAssertEqual(SleepStager.sessionRestingHR(start: 0, end: 600, hr: hr), 60, + "a one-sample bin cannot win the floor under the #1943 gate") + } + + // A sub-physiological mean (below 25 bpm) is excluded even when the bin is well-populated, so a + // dropout artefact sustained across several samples cannot become the resting HR either. + func testSessionRestingHRImplausibleBinCannotWinTheFloor() { + // 300 samples at 60 (qualifies), then 300 samples at 20 (well-populated but implausible). + // Before #1943 the floor was 20; now the implausible bin is excluded and the floor is 60. + let hr: [HRSample] = (0..<300).map { HRSample(ts: $0, bpm: 60) } + + (300..<600).map { HRSample(ts: $0, bpm: 20) } + XCTAssertEqual(SleepStager.sessionRestingHR(start: 0, end: 600, hr: hr), 60, + "a sub-25 bpm bin cannot win the floor under the #1943 gate") + } + + // When every bin is thin or implausible, the gate falls back to the lowest of ALL bin means + // (ungated), preserving the never-null-on-data behaviour. The floor is the same as before the + // gate existed. + func testSessionRestingHRAllBinsGatedFallsBackToUngatedMin() { + // Two bins, each with one sample: 40 and 50. Neither qualifies (both thin). The fallback + // is the min of all bin means = 40, the same value the ungated path produced. + let hr: [HRSample] = [HRSample(ts: 0, bpm: 40), HRSample(ts: 300, bpm: 50)] + XCTAssertEqual(SleepStager.sessionRestingHR(start: 0, end: 600, hr: hr), 40, + "when no bin qualifies, the floor falls back to the ungated min") + } + + // A dense, ordinary night (every bin well-populated and plausible) is unchanged by the gate. + func testSessionRestingHRDenseNightUnchangedByGate() { + let hr: [HRSample] = (0..<1800).map { HRSample(ts: $0, bpm: 60) } + XCTAssertEqual(SleepStager.sessionRestingHR(start: 0, end: 1800, hr: hr), 60, + "a well-populated night is unchanged by the gate") + } + + // The gate's threshold is exactly 5 samples: a bin with 5 qualifies, a bin with 4 does not. + func testSessionRestingHRBinSampleCountBoundary() { + // First bin: 300 samples at 70 (qualifies). Final bin: 4 samples at 40 (below threshold). + // The 4-sample bin is excluded; the floor is 70. + var hr: [HRSample] = (0..<300).map { HRSample(ts: $0, bpm: 70) } + hr.append(contentsOf: (0..<4).map { _ in HRSample(ts: 600, bpm: 40) }) + XCTAssertEqual(SleepStager.sessionRestingHR(start: 0, end: 600, hr: hr), 70, + "a 4-sample bin is below the minBinSamples=5 threshold and cannot win") + } + + // The plausibility gate's threshold is exactly 25 bpm: a mean of 25 qualifies, 24.9 does not. + func testSessionRestingHRPlausibilityBoundary() { + // First bin: 300 samples at 60 (qualifies). Final bin: 300 samples at 24 (implausible). + // The 24-bpm bin is excluded; the floor is 60. + let hr: [HRSample] = (0..<300).map { HRSample(ts: $0, bpm: 60) } + + (300..<600).map { HRSample(ts: $0, bpm: 24) } + XCTAssertEqual(SleepStager.sessionRestingHR(start: 0, end: 600, hr: hr), 60, + "a 24-bpm mean is below the minPlausibleBpm=25 threshold and cannot win") + } + + func testSessionHrvWindowsAlignedEndpointBeatsFillTheFinalWindow() { + // 120 beats in the opening window plus three beats exactly on an aligned end = 600. The + // second window is the final one, so it closes on `end` and holds those three beats; + // before the fix it was emitted empty and its RMSSD was nil. Constant 900 ms RR keeps the + // RMSSD analytically 0.0 on both platforms — this pins the BINNING, not the RMSSD math. + var rr: [RRInterval] = (0..<120).map { RRInterval(ts: $0, rrMs: 900) } + rr.append(contentsOf: (0..<3).map { _ in RRInterval(ts: 600, rrMs: 900) }) + let wins = SleepStager.sessionHrvWindows(start: 0, end: 600, rr: rr, stages: []) + XCTAssertEqual(wins.map { $0.startTs }, [0, 300]) + XCTAssertEqual(wins.map { $0.cleanBeats }, [120, 3], + "the endpoint beats must fill the final window") + XCTAssertEqual(wins.map { $0.rmssd }, [0.0, 0.0]) + } + + func testSessionHrvWindowsNonAlignedEndpointUnchanged() { + // Paired non-aligned case: end = 450 sits mid-window, so the endpoint beats were already + // inside the trailing partial window. Same shape, before and after. + var rr: [RRInterval] = (0..<120).map { RRInterval(ts: $0, rrMs: 900) } + rr.append(contentsOf: (0..<3).map { _ in RRInterval(ts: 450, rrMs: 900) }) + let wins = SleepStager.sessionHrvWindows(start: 0, end: 450, rr: rr, stages: []) + XCTAssertEqual(wins.map { $0.startTs }, [0, 300]) + XCTAssertEqual(wins.map { $0.cleanBeats }, [120, 3]) + } + + func testSessionAvgHRVZeroLengthWindowUsesTheEndpointBeats() { + // The value-level consequence: a zero-length window (start == end) is one closed bin, so + // beats admitted by the prefilter produce a number instead of nil. + let rr = (0..<3).map { _ in RRInterval(ts: 1000, rrMs: 900) } + XCTAssertEqual(SleepStager.sessionAvgHRV(start: 1000, end: 1000, rr: rr), 0.0) + } + // MARK: - Helper robustness func testConvolveReflectShortInputDoesNotCrash() { diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SlidingStreamWindowTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SlidingStreamWindowTests.swift new file mode 100644 index 0000000000..5c809559a4 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SlidingStreamWindowTests.swift @@ -0,0 +1,124 @@ +import XCTest +@testable import StrandAnalytics + +/// Pins the sliding read buffer against the only contract that matters: for every window, it returns +/// EXACTLY what a direct store read would have returned. Twin of Kotlin `SlidingStreamWindowTest`. +/// +/// The test store is a plain sorted array, so "what a direct read would have returned" is computable +/// independently rather than asserted from the implementation's own behaviour — otherwise the test would +/// agree with a wrong splice. +final class SlidingStreamWindowTests: XCTestCase { + + private let day = 86_400 + private let h30 = 108_000 + private let midnight = 1_787_875_200 + + private lazy var store: [Int] = Array(stride(from: midnight - 5 * day, through: midnight + day, by: 1)) + + private func direct(_ from: Int, _ to: Int, limit: Int = 1_000_000) -> [Int] { + Array(store.filter { $0 >= from && $0 <= to }.prefix(limit)) + } + + private func window(limit: Int = 1_000_000) -> SlidingStreamWindow { + SlidingStreamWindow(tsOf: { $0 }, limit: limit) { [self] _, f, t in direct(f, t, limit: limit) } + } + + /// Day 0 reads its whole 54 h window; each later day reads exactly one 24 h stride. Without the buffer + /// this walk reads 5 x 54 h; the union of all five windows is what it reads instead. + func testBackwardWalkMatchesDirectReadsAndReadsEachRowOnce() async { + let w = window() + for offset in 0..<5 { + let dayStart = midnight - offset * day + let from = dayStart - h30, to = dayStart + day + let got = await w.rows(owner: "owner", from: from, to: to) + XCTAssertEqual(got, direct(from, to), "window \(offset) must equal a direct read") + } + let lastFrom = midnight - 4 * day - h30 + XCTAssertEqual(w.rowsRead, direct(lastFrom, midnight + day).count, "each row read exactly once") + XCTAssertGreaterThan(w.rowsServed, w.rowsRead / 2, "and most rows came from the buffer") + } + + func testOwnerFlipFallsBackToADirectRead() async { + let w = window() + let from = midnight - h30, to = midnight + day + _ = await w.rows(owner: "a", from: from, to: to) + let readAfterFirst = w.rowsRead + let got = await w.rows(owner: "b", from: from - day, to: to - day) + XCTAssertEqual(got, direct(from - day, to - day)) + // A different strap cannot reuse the buffer, so this is a full window read, not a stride. + XCTAssertEqual(w.rowsRead - readAfterFirst, to - from + 1) + } + + /// A truncated read cannot be sliced, because `ORDER BY ts ASC LIMIT` drops the NEWEST rows — the + /// buffer would be missing its tail with nothing to say so. + /// The truncation counter is the one diagnostic here that means a SCORE may be wrong rather than + /// merely slow, so pin when it moves and when it does not. Twin of the Kotlin case of the same name. + func testTruncatedReadsCountsOnlyReadsThatLostRows() async { + let clean = window() + _ = await clean.rows(owner: "owner", from: midnight - h30, to: midnight + day) + XCTAssertEqual(clean.truncatedReads, 0, "a read under the cap lost nothing") + + let capped = window(limit: 1_000) + _ = await capped.rows(owner: "owner", from: midnight - h30, to: midnight + day) + XCTAssertEqual(capped.truncatedReads, 1, "a read AT the cap dropped its newest rows") + } + + /// Once a read is truncated the planner refuses to splice at all — `cachedTruncated` is its FIRST + /// guard — so every later window is a fresh full read. Each of those that is itself at the cap is a + /// separate lost tail and counts again: the number is windows-that-lost-rows, not passes. + /// + /// Deliberately NOT a test of the truncated-extension branch. That branch needs a buffer that is + /// valid but whose extension overruns the cap, and a truncated read can never leave a valid buffer, + /// so a uniform backward walk cannot reach it. + func testEachTruncatedWindowCountsSeparately() async { + let w = window(limit: 1_000) + _ = await w.rows(owner: "owner", from: midnight - h30, to: midnight + day) + let afterFirst = w.truncatedReads + _ = await w.rows(owner: "owner", from: midnight - day - h30, to: midnight) + XCTAssertEqual(w.truncatedReads, afterFirst + 1, "one increment per window that lost rows") + } + + func testTruncatedReadIsNeverSliced() async { + let limit = 1_000 + let w = window(limit: limit) + let from = midnight - h30, to = midnight + day + let first = await w.rows(owner: "owner", from: from, to: to) + XCTAssertEqual(first.count, limit) + let next = await w.rows(owner: "owner", from: from - day, to: to - day) + XCTAssertEqual(next, direct(from - day, to - day, limit: limit)) + } + + /// A gap (the day cache skipped a day) must not splice across the hole. + func testGapDoesNotSpliceAcrossTheMissingDay() async { + let w = window() + _ = await w.rows(owner: "owner", from: midnight - h30, to: midnight + day) + let skipStart = midnight - 2 * day + let got = await w.rows(owner: "owner", from: skipStart - h30, to: skipStart + day) + XCTAssertEqual(got, direct(skipStart - h30, skipStart + day)) + } + + /// A FAILED read must not be cached as if it were an empty range. Twin of Kotlin + /// `failedReadIsNotCachedAsEmpty`. + /// + /// This is the one that bites silently. An empty SUCCESSFUL read is a true statement — there are no + /// rows in that span — so the next day may splice against it. An empty FAILED read says nothing, and + /// caching it lets the following day splice against rows nobody fetched: its window would come back + /// missing everything the buffer claimed to hold, with no error and no log line, and the days built + /// from it would be scored on inputs that quietly lost hours. + /// + /// Reachable on THIS platform in particular: the engine wraps its store reads in `try?`, so a failure + /// arrives as an empty array unless the reader says otherwise. + func testFailedReadIsNotCachedAsEmpty() async { + var failNext = true + let w = SlidingStreamWindow(tsOf: { $0 }, limit: 1_000_000) { [self] _, f, t in + if failNext { failNext = false; return nil } + return direct(f, t) + } + let from = midnight - h30, to = midnight + day + let first = await w.rows(owner: "owner", from: from, to: to) + XCTAssertEqual(first, [], "a failed read yields nothing, as it did before the buffer existed") + // The next day must go back to the store, not splice against a window nobody filled. + let next = await w.rows(owner: "owner", from: from - day, to: to - day) + XCTAssertEqual(next, direct(from - day, to - day)) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SparseBridgeAttemptTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SparseBridgeAttemptTests.swift index 2a5f5f73ae..b42ddd4edc 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SparseBridgeAttemptTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SparseBridgeAttemptTests.swift @@ -23,7 +23,7 @@ final class SparseBridgeAttemptTests: XCTestCase { /// must equal the reduction in sleep-run count the REAL bridge produces. private func assertAgreesWithBridge(_ periods: [SleepStager.Period], hr: [HRSample], baseline: Double?, file: StaticString = #filePath, line: UInt = #line) { - let attempts = SleepStager.sparseBridgeAttempts(periods, sparse: true, hr: hr, baseline: baseline) + let attempts = SleepStager.bridgeSparseSleepTraced(periods, sparse: true, hr: hr, baseline: baseline).1 let before = periods.filter { $0.stage == "sleep" }.count let after = SleepStager.bridgeSparseSleep(periods, sparse: true, hr: hr, baseline: baseline) .filter { $0.stage == "sleep" }.count @@ -35,7 +35,7 @@ final class SparseBridgeAttemptTests: XCTestCase { // Two sleep runs 10 min apart, HR asleep across the gap → bridged. let periods = [sleep(0, 3_600), sleep(4_200, 8_000)] let hr = sleepHR(from: 0, to: 8_000) - let a = SleepStager.sparseBridgeAttempts(periods, sparse: true, hr: hr, baseline: 70) + let a = SleepStager.bridgeSparseSleepTraced(periods, sparse: true, hr: hr, baseline: 70).1 XCTAssertEqual(a.count, 1) XCTAssertEqual(a[0].gapMin, 10) XCTAssertTrue(a[0].bridged) @@ -48,7 +48,7 @@ final class SparseBridgeAttemptTests: XCTestCase { let gap = (SleepStager.sparseBridgeGapMin + 30) * 60 let periods = [sleep(0, 3_600), sleep(3_600 + gap, 3_600 + gap + 3_600)] let hr = sleepHR(from: 0, to: 3_600 + gap + 3_600) - let a = SleepStager.sparseBridgeAttempts(periods, sparse: true, hr: hr, baseline: 70) + let a = SleepStager.bridgeSparseSleepTraced(periods, sparse: true, hr: hr, baseline: 70).1 XCTAssertEqual(a.count, 1) XCTAssertFalse(a[0].bridged) XCTAssertEqual(a[0].reason, "gapTooLong") @@ -60,7 +60,7 @@ final class SparseBridgeAttemptTests: XCTestCase { // Gap is short enough, but HR across it is clearly awake → the HR condition is what refused. let periods = [sleep(0, 3_600), sleep(4_200, 8_000)] let hr = sleepHR(from: 0, to: 8_000, bpm: 110) - let a = SleepStager.sparseBridgeAttempts(periods, sparse: true, hr: hr, baseline: 60) + let a = SleepStager.bridgeSparseSleepTraced(periods, sparse: true, hr: hr, baseline: 60).1 XCTAssertEqual(a.count, 1) XCTAssertFalse(a[0].bridged) XCTAssertFalse(a[0].hrInSleepBand) @@ -68,21 +68,28 @@ final class SparseBridgeAttemptTests: XCTestCase { assertAgreesWithBridge(periods, hr: hr, baseline: 60) } - /// The reporter's shape (#737): fragments separated by ACTIVE runs are never adjacent sleep pairs, so - /// the bridge considers nothing — which is exactly why their log showed runsBefore == runsAfter with - /// no explanation. An EMPTY attempt list is the diagnosis, and the emitter says so in words. - func testFragmentsSeparatedByActiveRunsProduceNoAttempts() { + /// REPINNED for #1657. This used to assert the opposite — "an intervening active run is never a + /// considered pair" — which was true, and was the bug: the shape it describes is the reporter's + /// bathroom break, and the bridge built to rescue fragmentation was structurally unable to reach it. + /// A field trace then found the bridge merging nothing on 14 of 14 sparse nights for this reason. + /// + /// The test was correct when written and quietly became a guard on the wrong behaviour. Kept pointing + /// at the same shape so the history reads straight. + func testAShortActiveRunBetweenFragmentsIsNowConsideredAndBridged() { let periods = [sleep(0, 3_000), active(3_000, 3_300), sleep(3_300, 6_000)] let hr = sleepHR(from: 0, to: 6_000) - let a = SleepStager.sparseBridgeAttempts(periods, sparse: true, hr: hr, baseline: 70) - XCTAssertTrue(a.isEmpty, "an intervening active run is never a considered pair") - assertAgreesWithBridge(periods, hr: hr, baseline: 70) // and the real bridge merges nothing + let (out, a) = SleepStager.bridgeSparseSleepTraced(periods, sparse: true, hr: hr, baseline: 70) + XCTAssertEqual(a.count, 1, "the pair is now considered") + XCTAssertEqual(a.first?.reason, "bridged") + XCTAssertEqual(a.first?.activeMin, 5) + XCTAssertEqual(out.count, 1, "and the night is one run again") + XCTAssertEqual(out.first?.end, 6_000) } func testNoOpWhenNotSparse() { let periods = [sleep(0, 3_600), sleep(4_200, 8_000)] let hr = sleepHR(from: 0, to: 8_000) - XCTAssertTrue(SleepStager.sparseBridgeAttempts(periods, sparse: false, hr: hr, baseline: 70).isEmpty, + XCTAssertTrue(SleepStager.bridgeSparseSleepTraced(periods, sparse: false, hr: hr, baseline: 70).1.isEmpty, "the dense 4.0 path is untouched, so there is nothing to explain") } @@ -91,7 +98,7 @@ final class SparseBridgeAttemptTests: XCTestCase { func testChainOfThreeReportsTwoPairsAndAgrees() { let periods = [sleep(0, 3_600), sleep(4_200, 8_000), sleep(8_600, 12_000)] let hr = sleepHR(from: 0, to: 12_000) - let a = SleepStager.sparseBridgeAttempts(periods, sparse: true, hr: hr, baseline: 70) + let a = SleepStager.bridgeSparseSleepTraced(periods, sparse: true, hr: hr, baseline: 70).1 XCTAssertEqual(a.count, 2) XCTAssertTrue(a.allSatisfy { $0.bridged }) assertAgreesWithBridge(periods, hr: hr, baseline: 70) diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SseDeltasTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SseDeltasTests.swift new file mode 100644 index 0000000000..8ac0b6864e --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SseDeltasTests.swift @@ -0,0 +1,231 @@ +import XCTest +@testable import StrandAnalytics + +/// Byte-identity pin for the SSE delta parser. The same SSE lines MUST produce the same text as +/// the Android twin `com.noop.analytics.SseDeltasTest`. Cross-platform parity is the contract. +final class SseDeltasTests: XCTestCase { + + // MARK: - SSE line splitting + + func testStripsDataPrefix() { + XCTAssertEqual(SseDeltas.dataPayload(fromLine: "data: {\"hello\":1}"), "{\"hello\":1}") + } + + func testStripsDataPrefixNoSpace() { + XCTAssertEqual(SseDeltas.dataPayload(fromLine: "data:{\"hello\":1}"), "{\"hello\":1}") + } + + func testBlankLineReturnsNil() { + XCTAssertNil(SseDeltas.dataPayload(fromLine: "")) + XCTAssertNil(SseDeltas.dataPayload(fromLine: " ")) + } + + func testCommentLineReturnsNil() { + XCTAssertNil(SseDeltas.dataPayload(fromLine: ": keep-alive")) + } + + func testNonDataFieldReturnsNil() { + XCTAssertNil(SseDeltas.dataPayload(fromLine: "event: content_block_delta")) + XCTAssertNil(SseDeltas.dataPayload(fromLine: "id: 12345")) + XCTAssertNil(SseDeltas.dataPayload(fromLine: "retry: 1000")) + } + + func testIsDone() { + XCTAssertTrue(SseDeltas.isDone("[DONE]")) + XCTAssertTrue(SseDeltas.isDone(" [DONE] ")) + XCTAssertFalse(SseDeltas.isDone("{\"choices\":[]}")) + } + + // MARK: - CRLF parity (Kotlin trim() strips \r; Swift .whitespaces does not) + + func testIsDoneStripsCRLF() { + // A CRLF stream leaves a trailing \r after \n-splitting. Kotlin's trim() removes it; + // Swift must use .whitespacesAndNewlines to match, or isDone returns false on iOS. + XCTAssertTrue(SseDeltas.isDone("[DONE]\r")) + XCTAssertTrue(SseDeltas.isDone("[DONE]\r\n")) + } + + func testDataPayloadStripsCRLF() { + XCTAssertEqual(SseDeltas.dataPayload(fromLine: "data: {\"hello\":1}\r"), "{\"hello\":1}") + } + + // MARK: - OpenAI delta + + func testOpenAiContentDelta() { + let payload = #"{"choices":[{"index":0,"delta":{"content":"Hello"}}]}"# + XCTAssertEqual(SseDeltas.openAiDelta(payload), "Hello") + } + + func testOpenAiRoleDeltaReturnsNil() { + // The first chunk often carries only the role, no content. + let payload = #"{"choices":[{"index":0,"delta":{"role":"assistant"}}]}"# + XCTAssertNil(SseDeltas.openAiDelta(payload)) + } + + func testOpenAiDoneReturnsNil() { + XCTAssertNil(SseDeltas.openAiDelta("[DONE]")) + } + + func testOpenAiEmptyChoicesReturnsNil() { + XCTAssertNil(SseDeltas.openAiDelta(#"{"choices":[]}"#)) + } + + func testOpenAiMalformedReturnsNil() { + XCTAssertNil(SseDeltas.openAiDelta("not json")) + } + + func testOpenAiEmptyContentReturnsNil() { + // OpenAI's usual first delta carries `"content": ""`. Kotlin returns null (optString + + // takeIf { isNotEmpty() }); Swift must match so onDelta doesn't fire on one platform only + // (K14 haptics hang off delta arrival). + let payload = #"{"choices":[{"index":0,"delta":{"content":""}}]}"# + XCTAssertNil(SseDeltas.openAiDelta(payload)) + } + + // MARK: - Gemini delta + + func testGeminiTextDelta() { + let payload = #"{"candidates":[{"content":{"parts":[{"text":"Hello"}]}}]}"# + XCTAssertEqual(SseDeltas.geminiDelta(payload), "Hello") + } + + func testGeminiMultiPartDelta() { + let payload = #"{"candidates":[{"content":{"parts":[{"text":"Hello "},{"text":"world"}]}}]}"# + XCTAssertEqual(SseDeltas.geminiDelta(payload), "Hello world") + } + + func testGeminiFinishReasonChunkReturnsNil() { + let payload = #"{"candidates":[{"content":{"parts":[]},"finishReason":"STOP"}]}"# + XCTAssertNil(SseDeltas.geminiDelta(payload)) + } + + func testGeminiMalformedReturnsNil() { + XCTAssertNil(SseDeltas.geminiDelta("not json")) + } + + // MARK: - Anthropic delta + + func testAnthropicTextDelta() { + let payload = #"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"# + XCTAssertEqual(SseDeltas.anthropicDelta(payload), "Hello") + } + + func testAnthropicMessageStartReturnsNil() { + let payload = #"{"type":"message_start","message":{"id":"msg_1"}}"# + XCTAssertNil(SseDeltas.anthropicDelta(payload)) + } + + func testAnthropicPingReturnsNil() { + let payload = #"{"type":"ping"}"# + XCTAssertNil(SseDeltas.anthropicDelta(payload)) + } + + func testAnthropicContentBlockStartReturnsNil() { + let payload = #"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"# + XCTAssertNil(SseDeltas.anthropicDelta(payload)) + } + + func testAnthropicMessageStopReturnsNil() { + let payload = #"{"type":"message_stop"}"# + XCTAssertNil(SseDeltas.anthropicDelta(payload)) + } + + func testAnthropicMalformedReturnsNil() { + XCTAssertNil(SseDeltas.anthropicDelta("not json")) + } + + func testAnthropicEmptyTextReturnsNil() { + // Parity with Kotlin: empty text → null, not "" (same reason as OpenAI empty content). + let payload = #"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}"# + XCTAssertNil(SseDeltas.anthropicDelta(payload)) + } + + // MARK: - Full-stream reassembly (the parity pin: streamed == non-streamed) + + func testOpenAiReassembleMatchesFullReply() { + // A realistic 3-chunk OpenAI stream for the reply "Hello, world!". + let lines = [ + "data: {\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"}}]}", + "", + "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"}}]}", + "", + "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\", world!\"}}]}", + "", + "data: {\"choices\":[{\"index\":0,\"finish_reason\":\"stop\"}]}", + "", + "data: [DONE]", + "", + ] + let reassembled = SseDeltas.reassemble(lines: lines, provider: .openAi) + // The non-streamed reply would be "Hello, world!" (the full content from choices[0].message.content). + XCTAssertEqual(reassembled, "Hello, world!") + } + + func testGeminiReassembleMatchesFullReply() { + let lines = [ + "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Recovery\"}]}}]}", + "", + "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\" is \"}]}}]}", + "", + "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"good.\"}]},\"finishReason\":\"STOP\"}]}", + "", + ] + XCTAssertEqual(SseDeltas.reassemble(lines: lines, provider: .gemini), "Recovery is good.") + } + + func testAnthropicReassembleMatchesFullReply() { + let lines = [ + "event: message_start", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\"}}", + "", + "event: content_block_start", + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}", + "", + "event: content_block_delta", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Sleep\"}}", + "", + "event: content_block_delta", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" well.\"}}", + "", + "event: content_block_stop", + "data: {\"type\":\"content_block_stop\",\"index\":0}", + "", + "event: message_stop", + "data: {\"type\":\"message_stop\"}", + "", + ] + XCTAssertEqual(SseDeltas.reassemble(lines: lines, provider: .anthropic), "Sleep well.") + } + + func testCustomSharesOpenAiShape() { + let lines = [ + "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hi\"}}]}", + "", + "data: [DONE]", + "", + ] + XCTAssertEqual(SseDeltas.reassemble(lines: lines, provider: .custom), "Hi") + } + + // MARK: - Empty / edge cases + + func testEmptyStreamReassemblesToEmpty() { + XCTAssertEqual(SseDeltas.reassemble(lines: [], provider: .openAi), "") + } + + func testOnlyDoneReassemblesToEmpty() { + XCTAssertEqual(SseDeltas.reassemble(lines: ["data: [DONE]", ""], provider: .openAi), "") + } + + func testIgnoresCommentLines() { + let lines = [ + ": keep-alive", + "", + "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"}}]}", + "", + "data: [DONE]", + "", + ] + XCTAssertEqual(SseDeltas.reassemble(lines: lines, provider: .openAi), "ok") + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StepsCounterTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StepsCounterTests.swift index 4c838ad56c..4b4c561c59 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StepsCounterTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StepsCounterTests.swift @@ -8,7 +8,9 @@ import WhoopProtocol /// the caller's `stepTicksPerStep` calibration). Mirrors the Android StepsCounterTest vectors value-for-value. final class StepsCounterTests: XCTestCase { - private func step(_ ts: Int, _ counter: Int) -> StepSample { StepSample(ts: ts, counter: counter) } + private func step(_ ts: Int, _ counter: Int, _ activityClass: Int? = nil) -> StepSample { + StepSample(ts: ts, counter: counter, activityClass: activityClass) + } func testSumsPositiveConsecutiveDeltas() { // counters 100 -> 150 -> 220 => deltas 50 + 70 = 120 @@ -44,7 +46,32 @@ final class StepsCounterTests: XCTestCase { func testMaxStepDeltaBoundaryIsExclusive() { // Exactly maxStepDelta (512) is dropped; 511 counts. - XCTAssertEqual(StepsCounter.stepsInWindow([step(0, 0), step(60, 512)]), nil) // 512 dropped => no movement - XCTAssertEqual(StepsCounter.stepsInWindow([step(0, 0), step(60, 511)]), 511) // 511 kept + XCTAssertEqual(StepsCounter.stepsInWindow([step(0, 0), step(128, 512)]), nil) // absolute guard + XCTAssertEqual(StepsCounter.stepsInWindow([step(0, 0), step(128, 511)]), 511) + } + + func testRejectsPhysicallyImpossibleOneSecondSpikeButAllowsSameTicksAcrossTime() { + XCTAssertNil(StepsCounter.stepsInWindow([step(0, 100), step(1, 107)])) + XCTAssertEqual(StepsCounter.stepsInWindow([step(0, 100), step(2, 107)]), 7) + XCTAssertEqual(StepsCounter.stepsInWindow([step(0, 100), step(1, 104)]), 4) + } + + func testClassedStreamCountsOnlyWalkAndRunDeltas() { + // Attribute each counter delta to the later sample, matching the strap's per-record class. + // still: +10 ignored; walk: +20; run: +15; unknown: +25 ignored => 35 locomotion ticks. + XCTAssertEqual(StepsCounter.stepsInWindow([ + step(0, 100, 0), + step(10, 110, 0), + step(20, 130, 1), + step(30, 145, 2), + step(40, 170, nil), + ]), 35) + } + + func testLegacyUnclassedStreamKeepsCounterFallback() { + // Rows written before activityClass existed must retain their historical estimate. + XCTAssertEqual(StepsCounter.stepsInWindow([ + step(0, 100), step(10, 140), step(20, 170), + ]), 70) } } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StepsDailyTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StepsDailyTests.swift index ff331aceda..21faf02666 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StepsDailyTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StepsDailyTests.swift @@ -58,7 +58,7 @@ final class StepsDailyTests: XCTestCase { func testJumpGuardDropsGapButKeepsRealSteps() { // 100 -> 300 (=200 real) ; 300 -> 1200 is a 900-tick GAP (>= 512) and is dropped ; 1200 -> 1500 // (=300 real). Only the two in-range increments count => 200 + 300 = 500, the gap doesn't inflate. - let s = [step(0, 100), step(60, 300), step(3_600, 1_200), step(3_660, 1_500)] + let s = [step(0, 100), step(60, 300), step(3_600, 1_200), step(3_675, 1_500)] XCTAssertEqual(stepsFor(s), 500) } @@ -66,7 +66,7 @@ final class StepsDailyTests: XCTestCase { // THE BUG (#132/#276/#316). A realistic ascending cumulative counter sampled at 1 Hz. The OLD // code summed the raw running total (here, byte @57 alone summed) — exploding the count; the NEW // wrap-aware diff sums only the per-record increments and yields a sane number. - let counters = [100, 127, 127, 130, 131, 131, 140, 152, 160, 175] + let counters = [100, 102, 102, 105, 106, 106, 109, 111, 113, 115] let samples = counters.enumerated().map { step($0.offset, $0.element) } // NEW behaviour: sum of wrap-aware deltas == last - first (all small increments, none >= 512). let sane = counters.last! - counters.first! // 75 diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StreamReadCapTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StreamReadCapTests.swift new file mode 100644 index 0000000000..26baf206bf --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StreamReadCapTests.swift @@ -0,0 +1,72 @@ +import Foundation +import XCTest +@testable import StrandAnalytics + +/// Per-stream read caps (#1538) — twin of the Kotlin `StreamReadCapTest`, same cases and same numbers. +final class StreamReadCapTests: XCTestCase { + + /// THE invariant. A cap must exceed what a full window can legitimately hold, or a complete read is + /// indistinguishable from a truncated one — and the truncated one silently loses its newest rows. + func testCapExceedsAFullWindowForEveryStream() { + let fullHR = Double(StreamReadCap.windowSeconds) * StreamReadCap.hrRowsPerSecond + let fullRR = Double(StreamReadCap.windowSeconds) * StreamReadCap.rrRowsPerSecond + XCTAssertGreaterThan(Double(StreamReadCap.hr), fullHR) + XCTAssertGreaterThan(Double(StreamReadCap.rr), fullRR) + } + + /// The test above is derived from the same constants as the caps, so on its own it only asserts that + /// headroom exceeds 1. This one is anchored OUTSIDE the type, to a number the field produced: R-R + /// came back at 200,000 ten times in one capture, so that value is known-insufficient rather than + /// theorised. A cap at or below it would reintroduce the bug no matter how the arithmetic reads. + func testCapsClearTheValueThatTruncatedInTheField() { + let knownInsufficient = 200_000 + XCTAssertGreaterThan(StreamReadCap.rr, knownInsufficient) + XCTAssertGreaterThan(StreamReadCap.hr, knownInsufficient, + "HR sat 3% under this and was lucky, not safe") + } + + /// The window is not restated here — the engine reads `dayStart - lookbackSeconds`, so these are the + /// same number by construction. Pinned so that splitting them again is a visible change. + func testWindowIsItsTwoHalves() { + XCTAssertEqual(StreamReadCap.windowSeconds, + StreamReadCap.lookbackSeconds + StreamReadCap.forwardSeconds) + XCTAssertEqual(StreamReadCap.lookbackSeconds, 30 * 3_600) + XCTAssertEqual(StreamReadCap.forwardSeconds, 86_400) + } + + /// The regression itself, in the numbers that caused it. The old shared cap of 200,000 was ABOVE a + /// full HR window and BELOW a full R-R one — which is exactly why HR never truncated, R-R always did, + /// and one number looked adequate from the HR side. + func testTheOldSharedCapWasBelowAFullRRWindow() { + let oldSharedCap = 200_000.0 + let fullHR = Double(StreamReadCap.windowSeconds) * StreamReadCap.hrRowsPerSecond + let fullRR = Double(StreamReadCap.windowSeconds) * StreamReadCap.rrRowsPerSecond + XCTAssertGreaterThan(oldSharedCap, fullHR, "the old cap fitted HR, which is why it looked fine") + XCTAssertLessThan(oldSharedCap, fullRR, "and did not fit R-R, which is why nights were clipped") + XCTAssertGreaterThan(Double(StreamReadCap.rr), fullRR, "the new cap does fit it") + } + + /// R-R must be capped higher than HR: it is one row per BEAT, not one per second. + func testRRIsCappedHigherThanHR() { + XCTAssertGreaterThan(StreamReadCap.rr, StreamReadCap.hr) + } + + /// The window is 54 hours — `dayStart - 30h` running through the night. Pinned because both caps are + /// derived from it, so a silent change here would resize them both. + func testWindowIsFiftyFourHours() { + XCTAssertEqual(StreamReadCap.windowSeconds, 54 * 3_600) + XCTAssertEqual(StreamReadCap.hr, 291_600) + XCTAssertEqual(StreamReadCap.rr, 583_200) + XCTAssertEqual(StreamReadCap.gravity, 291_600) + } + + /// Gravity is the third stream on the 54-hour window, and the field capture put it at 192,698 rows - + /// 96% of the old shared cap. Unlike HR and R-R it is a PLAIN read with no truncation counter, so a + /// clip there reports nothing at all; sleep staging simply gets a night missing its tail. + func testGravityClearsWhatTheFieldMeasured() { + let measuredInField = 192_698 + XCTAssertGreaterThan(StreamReadCap.gravity, measuredInField) + XCTAssertGreaterThan(StreamReadCap.gravity, 200_000, "the old shared cap it sat 96% of") + } + +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WindowedStreamPlanTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WindowedStreamPlanTests.swift new file mode 100644 index 0000000000..366218942e --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WindowedStreamPlanTests.swift @@ -0,0 +1,89 @@ +import XCTest +@testable import StrandAnalytics + +/// Pins the windowed-read planner. Twin of Kotlin `WindowedStreamPlanTest` — same table, same answers. +/// +/// Two properties matter here and nothing else does. The walk must collapse to ONE stride of reading per +/// day, which is the whole point; and every case the planner cannot prove safe must land on `.fullRead`, +/// because this sits on the scoring path where an optimisation that guesses produces a wrong number rather +/// than a slow one. +final class WindowedStreamPlanTests: XCTestCase { + + private let day = 86_400 + private let h30 = 108_000 + private let midnight = 1_787_875_200 + + /// The real shape: days walked backwards, 54-hour windows on a 24-hour stride. Day 0 reads in full + /// because nothing is buffered; every day after it extends by EXACTLY one stride, which is the 2.25x + /// overlap being removed. + func testBackwardWalkExtendsByExactlyOneStridePerDay() { + var owner: String? + var cf = 0, ct = 0 + var plans: [WindowedStreamPlan.Plan] = [] + for offset in 0..<5 { + let dayStart = midnight - offset * day + let from = dayStart - h30, to = dayStart + day + plans.append(WindowedStreamPlan.plan(cachedOwner: owner, cachedFrom: cf, cachedTo: ct, + cachedTruncated: false, owner: "w", from: from, to: to)) + owner = "w"; cf = from; ct = to + } + XCTAssertEqual(plans[0], .fullRead) + XCTAssertEqual(plans[1], .extend(readFrom: 1_787_680_800, readTo: 1_787_767_199)) + XCTAssertEqual(plans[2], .extend(readFrom: 1_787_594_400, readTo: 1_787_680_799)) + XCTAssertEqual(plans[3], .extend(readFrom: 1_787_508_000, readTo: 1_787_594_399)) + XCTAssertEqual(plans[4], .extend(readFrom: 1_787_421_600, readTo: 1_787_507_999)) + // Each extension is one stride, inclusive at both ends — the store's range predicate is too. + for p in plans.dropFirst() { + guard case let .extend(readFrom, readTo) = p else { return XCTFail("expected .extend, got \(p)") } + XCTAssertEqual(readTo - readFrom + 1, day) + } + } + + /// Everything the planner cannot prove safe reads in full — the pre-existing behaviour, unchanged. + func testUnprovableCasesReadInFull() { + func p(_ co: String?, _ cf: Int, _ ct: Int, _ trunc: Bool, _ o: String, _ f: Int, _ t: Int) + -> WindowedStreamPlan.Plan { + WindowedStreamPlan.plan(cachedOwner: co, cachedFrom: cf, cachedTo: ct, + cachedTruncated: trunc, owner: o, from: f, to: t) + } + XCTAssertEqual(p("a", 100, 200, false, "b", 50, 150), .fullRead) + XCTAssertEqual(p("w", 100, 200, true, "w", 50, 150), .fullRead) + XCTAssertEqual(p(nil, 0, 0, false, "w", 50, 150), .fullRead) + XCTAssertEqual(p("w", 100, 200, false, "w", 10, 99), .fullRead) + XCTAssertEqual(p("w", 100, 200, false, "w", 150, 250), .fullRead) + XCTAssertEqual(p("w", 200, 100, false, "w", 50, 150), .fullRead) + XCTAssertEqual(p("w", 100, 200, false, "w", 150, 50), .fullRead) + } + + /// A day whose window the buffer already covers reads nothing. The boundary case is the one worth + /// pinning: `to` landing exactly ON the buffer's start still extends only up to the row below it, so the + /// boundary row is never read twice. + func testCoveredServesAndBoundaryNeverDoubleReads() { + func p(_ cf: Int, _ ct: Int, _ f: Int, _ t: Int) -> WindowedStreamPlan.Plan { + WindowedStreamPlan.plan(cachedOwner: "w", cachedFrom: cf, cachedTo: ct, + cachedTruncated: false, owner: "w", from: f, to: t) + } + XCTAssertEqual(p(100, 200, 120, 180), .serve) + XCTAssertEqual(p(100, 200, 100, 200), .serve) + XCTAssertEqual(p(100, 200, 50, 150), .extend(readFrom: 50, readTo: 99)) + XCTAssertEqual(p(100, 200, 50, 100), .extend(readFrom: 50, readTo: 99)) + } + + /// The saved-rows line. Twin of Kotlin `logLineShape` — same inputs, same string, and the shape is + /// pinned because a diagnostic nobody asserts is a diagnostic that can drift into meaning nothing. + /// + /// The zero case is the one worth having: a pass where the windows decline entirely must SAY so rather + /// than simply omit the line, so a reader can tell "the buffer saved nothing" from "the buffer was + /// never asked". + func testLogLine() { + XCTAssertEqual( + WindowedStreamPlan.logLine(hrRead: 1_000, hrServed: 9_000, hrTruncated: 0, + rrRead: 250, rrServed: 750, rrTruncated: 3), + "analyzeRecent windows hr[read=1000 served=9000 truncated=0] " + + "rr[read=250 served=750 truncated=3]") + XCTAssertEqual( + WindowedStreamPlan.logLine(hrRead: 0, hrServed: 0, hrTruncated: 0, + rrRead: 0, rrServed: 0, rrTruncated: 0), + "analyzeRecent windows hr[read=0 served=0 truncated=0] rr[read=0 served=0 truncated=0]") + } +} diff --git a/Packages/StrandDesign/Sources/StrandDesign/Palette.swift b/Packages/StrandDesign/Sources/StrandDesign/Palette.swift index 0e258d579b..c0a2adc2de 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/Palette.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/Palette.swift @@ -276,6 +276,16 @@ public enum StrandPalette { public static var restDeep: Color { isClassic ? Color(light: "#203E73", dark: "#2A4C8F") : Color(light: "#234F9E", dark: "#2F6FCB") } public static var restBright: Color { isClassic ? Color(light: "#6A4FC0", dark: "#8E6FD6") : Color(light: "#5790DA", dark: "#6FA8E8") } public static var restGlow: Color { isClassic ? Color(light: "#3A80D6", dark: "#6FA8E8") : Color(light: "#3A80D6", dark: "#4A90E2") } + /// The Rest family's most legible LINE colour — for strokes that must read on a busy or translucent + /// surface, such as the body-clock dial's arcs over a custom background image. + /// + /// Introduces no new value: it selects the existing token that is the bright blue in each palette. + /// The families are not parallel — classic's `restBright` is a PURPLE accent while modern's is the + /// blue, and classic's `restColor` is the blue where modern's is a muted steel — so a card naming + /// either token directly gets the right colour in one palette and the wrong one in the other. Both + /// resolve to #6FA8E8 in dark. + public static var restLine: Color { isClassic ? restColor : restBright } + public static var restGradient: Gradient { Gradient(colors: [restDeep, restBright]) } /// Stress — blue→gold→orange world / Classic green→amber→red. diff --git a/Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings b/Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings index df894b30cd..5af055cf65 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings +++ b/Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings @@ -2308,6 +2308,12 @@ "state": "translated", "value": "51 bpm" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "51 уд/мин" + } } } }, @@ -2342,6 +2348,12 @@ "state": "translated", "value": "7h 42m śpi · 92% ewydajność" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "7 ч 42 мин сна · эффективность 92%" + } } } }, @@ -2376,6 +2388,12 @@ "state": "translated", "value": "87% · streaming" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "87% · передача" + } } } }, @@ -2399,6 +2417,12 @@ "value": "Ajouter" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавить" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -2450,6 +2474,12 @@ "state": "translated", "value": "Przyjęte przez NoopButtonStyle" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Применяется через NoopButtonStyle" + } } } }, @@ -2484,6 +2514,12 @@ "state": "translated", "value": "Tylko bar" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Только полоса" + } } } }, @@ -2518,6 +2554,12 @@ "state": "translated", "value": "Body / Regular 15 — wątek o Tobie, przeczytaj w całości." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Body / Regular 15 — нить о вас, прочитанная целиком." + } } } }, @@ -2552,6 +2594,12 @@ "state": "translated", "value": "PODLICZANIE WYNIKÓW" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ОЦЕНКА С ОТСЧЁТОМ" + } } } }, @@ -2586,6 +2634,12 @@ "state": "translated", "value": "Spokojna noc" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Спокойная ночь" + } } } }, @@ -2654,6 +2708,12 @@ "state": "translated", "value": "Karta %lld" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Карточка %lld" + } } } }, @@ -2677,6 +2737,12 @@ "value": "Terminé" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Готово" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -2762,6 +2828,12 @@ "state": "translated", "value": "HRV (ms) — 30 dni" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ВСР (мс) — 30 дней" + } } } }, @@ -2830,6 +2902,12 @@ "state": "translated", "value": "Tętno, 24 godziny" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пульс, 24 часа" + } } } }, @@ -2864,6 +2942,12 @@ "state": "translated", "value": "Najedź kursorem na zespół: nazwa sceny, początek i koniec zegara oraz czas trwania." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Наведите на полосу: название стадии, время начала и конца и длительность." + } } } }, @@ -2898,6 +2982,12 @@ "state": "translated", "value": "Najedź kursorem na komórkę: pierścień + data, wynik i podpowiedź dotycząca stanu odzyskiwania." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Наведите на ячейку: кольцо и дата, оценка и подсказка о состоянии восстановления." + } } } }, @@ -2932,6 +3022,12 @@ "state": "translated", "value": "Najedź kursorem na wskaźnik obciążenia i podpowiedzi dotyczącego słowa ładowania." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Наведите на индикатор, чтобы увидеть подсказку с нагрузкой и её словесной оценкой." + } } } }, @@ -2966,6 +3062,12 @@ "state": "translated", "value": "Najedź kursorem na pierścień, aby odzyskać + podpowiedź ze słowem stanu." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Наведите на кольцо, чтобы увидеть подсказку с восстановлением и его словесной оценкой." + } } } }, @@ -3000,6 +3102,12 @@ "state": "translated", "value": "Najedź kursorem na dowolną linię przebiegu, aby odczytać dokładną próbkę pod kursorem." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Наведите на любой мини-график, чтобы прочитать точное значение под курсором." + } } } }, @@ -3034,6 +3142,12 @@ "state": "translated", "value": "Najedź kursorem na karty, aby zobaczyć windę." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Наведите на карточки, чтобы увидеть подъём." + } } } }, @@ -3068,6 +3182,12 @@ "state": "translated", "value": "Najedź kursorem na linię: celownik + kropka + podpowiedź z datą/wartością." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Наведите на линию: перекрестие, точка и подсказка с датой и значением." + } } } }, @@ -3102,6 +3222,12 @@ "state": "translated", "value": "Najedź kursorem, aby przeczytać HRV każdego dnia w ms." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Наведите, чтобы прочитать ВСР каждого дня в мс." + } } } }, @@ -3136,6 +3262,12 @@ "state": "translated", "value": "W karcie" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "В карточке" + } } } }, @@ -3170,6 +3302,12 @@ "state": "translated", "value": "Ubiegłej nocy" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Последняя ночь" + } } } }, @@ -3204,6 +3342,12 @@ "state": "translated", "value": "Ruch podczas snu" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Движение во сне" + } } } }, @@ -3272,6 +3416,12 @@ "state": "translated", "value": "Przegląd HR" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пульс, обзор" + } } } }, @@ -3306,6 +3456,12 @@ "state": "translated", "value": "GRADIDENT ODZYSKU" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ГРАДИЕНТ ВОССТАНОВЛЕНИЯ" + } } } }, @@ -3340,6 +3496,12 @@ "state": "translated", "value": "Regeneracja — 30 dni" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Восстановление — 30 дней" + } } } }, @@ -3374,6 +3536,12 @@ "state": "translated", "value": "Regeneracja — ubiegły rok" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Восстановление — за прошлый год" + } } } }, @@ -3408,6 +3576,12 @@ "state": "translated", "value": "Zmniejsz ruch renderuje statycznie w ostatniej klatce." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "При включённом «Уменьшении движения» отображается статичный последний кадр." + } } } }, @@ -3431,6 +3605,12 @@ "value": "Supprimer" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалить" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -3482,6 +3662,12 @@ "state": "translated", "value": "Odtwórz ponownie" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Повторить появление" + } } } }, @@ -3505,6 +3691,12 @@ "value": "FC au repos" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пульс в покое" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -3556,6 +3748,12 @@ "state": "translated", "value": "Niespokojna noc" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Беспокойная ночь" + } } } }, @@ -3590,6 +3788,12 @@ "state": "translated", "value": "Rzuć numerem" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Прокрутить число" + } } } }, @@ -3624,6 +3828,12 @@ "state": "translated", "value": "MIĘKKIE PRZEJŚCIE KARTY" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "МЯГКИЙ ПЕРЕХОД КАРТОЧКИ" + } } } }, @@ -3658,6 +3868,12 @@ "state": "translated", "value": "WYGLĄDAJĄ ZNACZNIE" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ПОСЛЕДОВАТЕЛЬНОЕ ПОЯВЛЕНИЕ" + } } } }, @@ -3692,6 +3908,12 @@ "state": "translated", "value": "RAMPA OBCIĄŻENIA" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "НАРАСТАНИЕ НАГРУЗКИ" + } } } }, @@ -3715,6 +3937,12 @@ "value": "Enregistrer" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сохранить" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -3766,6 +3994,12 @@ "state": "translated", "value": "Liczba segmentów i wysokości" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Количество и высота сегментов" + } } } }, @@ -3800,6 +4034,12 @@ "state": "translated", "value": "Wydajność snu" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Качество сна" + } } } }, @@ -3834,6 +4074,12 @@ "state": "translated", "value": "Etapy snu" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Стадии сна" + } } } }, @@ -3970,6 +4216,12 @@ "state": "translated", "value": "Przełącz delikatną wiosnę" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переключить мягкую пружину" + } } } }, @@ -4004,6 +4256,12 @@ "state": "translated", "value": "Wartości" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Значения" + } } } }, @@ -4072,6 +4330,12 @@ "state": "translated", "value": "godzina %lld" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "час %lld" + } } } }, diff --git a/Packages/StrandImport/Sources/StrandImport/AppleHealthImporter.swift b/Packages/StrandImport/Sources/StrandImport/AppleHealthImporter.swift index 5c23641340..00bf923510 100644 --- a/Packages/StrandImport/Sources/StrandImport/AppleHealthImporter.swift +++ b/Packages/StrandImport/Sources/StrandImport/AppleHealthImporter.swift @@ -1,4 +1,9 @@ import Foundation +#if canImport(FoundationXML) +// corelibs Foundation moved the event-driven XML parser into its own module; Darwin keeps it in +// Foundation. Conditional so one import serves both, with no #if at the use sites. +import FoundationXML +#endif import ZIPFoundation /// Parses an Apple Health export (`export.xml`, possibly inside `export.zip`) diff --git a/Packages/StrandImport/Sources/StrandImport/CorelibsCompat.swift b/Packages/StrandImport/Sources/StrandImport/CorelibsCompat.swift new file mode 100644 index 0000000000..4867b5f83e --- /dev/null +++ b/Packages/StrandImport/Sources/StrandImport/CorelibsCompat.swift @@ -0,0 +1,19 @@ +import Foundation + +// Small corelibs shims so the package builds on Linux as well as Darwin. Each one exists because a +// Foundation facility this package uses is Objective-C-runtime-backed on Apple platforms and simply +// absent elsewhere — never because behaviour should differ. + +#if !canImport(ObjectiveC) +/// No-op stand-in for Apple's `autoreleasepool`. +/// +/// Without an Objective-C runtime there are no autoreleased temporaries to drain, so running the body +/// directly is the exact equivalent rather than an approximation. The Darwin call sites keep their real +/// pool, which is load-bearing there: the Apple Health importer drains per XML element because a +/// multi-year `export.xml` bridges tens of millions of attribute dictionaries. +/// +/// Mirrors the shim GRDB already ships for the same reason, so the two agree on the spelling. +@inlinable func autoreleasepool(invoking body: () throws -> Result) rethrows -> Result { + try body() +} +#endif diff --git a/Packages/StrandImport/Sources/StrandImport/GpxParser.swift b/Packages/StrandImport/Sources/StrandImport/GpxParser.swift index 2623001ecf..29b1ab9806 100644 --- a/Packages/StrandImport/Sources/StrandImport/GpxParser.swift +++ b/Packages/StrandImport/Sources/StrandImport/GpxParser.swift @@ -1,4 +1,9 @@ import Foundation +#if canImport(FoundationXML) +// corelibs Foundation moved the event-driven XML parser into its own module; Darwin keeps it in +// Foundation. Conditional so one import serves both, with no #if at the use sites. +import FoundationXML +#endif // MARK: - GPX parser (the universal GPS-track format) // diff --git a/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift b/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift index 8538fe9198..9e3e773822 100644 --- a/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift +++ b/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift @@ -154,4 +154,70 @@ public enum HealthWriteback { } return out } + + // MARK: - Apple Health external UUID keys (#1503) + + /// The deterministic `HKMetadataKeyExternalUUID` for a NOOP-written Apple Health record. + /// + /// The key is a natural key — `noop::` — with NO device-id segment. The previous + /// scheme embedded the active strap id (`noop:::`), which is NOT durable: + /// a re-pair or serial-based identification changes it, and every record written under the old id + /// becomes unreachable to the delete-then-write reconciliation (the delete predicate is built from + /// the id that is active NOW). The stranded records stay in Apple Health permanently as duplicates. + /// + /// Dropping the id segment matches the Android twin (`HealthConnectWriter.clientRecordId` carries + /// no device id — `noop-workout-`) and makes the key mean what it is: a natural key over + /// the record's own identity, stable across strap lifecycle changes. + /// + /// `kind` is the metric's `HKQuantityTypeIdentifier.rawValue` for vitals, `"sleep"` for sleep + /// sessions, or `"workout"` for workouts. `identity` is the day key ("yyyy-MM-dd") for vitals or + /// the unix-second start timestamp for sleep/workout. + public static func appleHealthExternalUUID(kind: String, identity: String) -> String { + "noop:\(kind):\(identity)" + } + + /// The vitals key: `noop::`. + public static func appleHealthVitalKey(metricId: String, day: String) -> String { + appleHealthExternalUUID(kind: metricId, identity: day) + } + + /// The sleep key: `noop:sleep:`. + public static func appleHealthSleepKey(startTs: Int) -> String { + appleHealthExternalUUID(kind: "sleep", identity: "\(startTs)") + } + + /// The workout key: `noop:workout:`. + public static func appleHealthWorkoutKey(startTs: Int) -> String { + appleHealthExternalUUID(kind: "workout", identity: "\(startTs)") + } + + // MARK: - #1503 stranded-records sweep completion tracking + // + // The one-off sweep that clears Apple Health records written under the OLD device-id-keyed + // scheme must be PER-TYPE, not a single boolean. A single flag set unconditionally loses the + // migration permanently when authorization is partial (sleep granted, workouts declined) or + // when a `deleteObjects` call fails — both of which read as "already done" and leave the + // stranded records unreachable forever. Per-type tracking lets an install that could not + // complete the sweep finish it later, once the missing authorization arrives or the failing + // delete succeeds on a retry. + // + // These pure helpers model the decision so it is unit-testable without HealthKit (HealthKit + // itself can't run under `swift test`). The app-target migration (`HealthKitBridge`) loads the + // already-swept set from UserDefaults, computes the types to attempt, attempts each delete, and + // records only the types whose delete SUCCEEDED back into the swept set. + + /// The type ids that should be attempted this run: authorized for sharing but not yet swept. + /// A type that was declined (absent from `authorized`) is never attempted and never marked + /// swept, so a later grant still gets swept. A type whose delete failed (not in `swept`) is + /// retried on the next write-back run. + public static func strandedSweepPending(swept: Set, authorized: Set) -> Set { + authorized.subtracting(swept) + } + + /// The new swept set after this run: the union of previously swept types and the types whose + /// delete SUCCEEDED this run. A type whose delete threw (absent from `succeededThisRun`) is NOT + /// marked swept, so it is retried next run rather than silently abandoned. + public static func strandedSweepResult(swept: Set, succeededThisRun: Set) -> Set { + swept.union(succeededThisRun) + } } diff --git a/Packages/StrandImport/Sources/StrandImport/RouteExporter.swift b/Packages/StrandImport/Sources/StrandImport/RouteExporter.swift index 0e0ade92d9..8563821d9f 100644 --- a/Packages/StrandImport/Sources/StrandImport/RouteExporter.swift +++ b/Packages/StrandImport/Sources/StrandImport/RouteExporter.swift @@ -39,8 +39,7 @@ public enum RouteExporter { ) -> Data { switch format { case .gpx: - return Data(buildGpx(route: route, startTs: startTs, endTs: endTs, sport: sport, - distanceM: distanceM).utf8) + return Data(buildGpx(route: route, startTs: startTs, endTs: endTs, sport: sport).utf8) case .fit: return buildFit(route: route, startTs: startTs, endTs: endTs, sport: sport, distanceM: distanceM, energyKcal: energyKcal, avgHr: avgHr, maxHr: maxHr) @@ -48,12 +47,13 @@ public enum RouteExporter { } /// GPX 1.1 track. Each `trkpt` carries lat/lon + an interpolated `time`; no `ele`/HR (none stored). + /// Takes no distance: GPX 1.1 has no track-level distance element, so a reader derives it from the + /// trackpoints. FIT does carry one, which is why ``buildFit`` still takes it. Kotlin twin matches. public static func buildGpx( route: [RoutePoint], startTs: Int, endTs: Int, - sport: String?, - distanceM: Double? = nil + sport: String? ) -> String { let canon = canonicalSport(sport) let times = interpolatedTimes(route.count, startTs, endTs) diff --git a/Packages/StrandImport/Sources/StrandImport/TcxParser.swift b/Packages/StrandImport/Sources/StrandImport/TcxParser.swift index 6a393c492f..40d9040afa 100644 --- a/Packages/StrandImport/Sources/StrandImport/TcxParser.swift +++ b/Packages/StrandImport/Sources/StrandImport/TcxParser.swift @@ -1,4 +1,9 @@ import Foundation +#if canImport(FoundationXML) +// corelibs Foundation moved the event-driven XML parser into its own module; Darwin keeps it in +// Foundation. Conditional so one import serves both, with no #if at the use sites. +import FoundationXML +#endif // MARK: - TCX parser (Garmin Training Center XML) // diff --git a/Packages/StrandImport/Sources/StrandImport/WhoopExportImporter.swift b/Packages/StrandImport/Sources/StrandImport/WhoopExportImporter.swift index c59d2767e1..b147014014 100644 --- a/Packages/StrandImport/Sources/StrandImport/WhoopExportImporter.swift +++ b/Packages/StrandImport/Sources/StrandImport/WhoopExportImporter.swift @@ -268,7 +268,16 @@ public struct WhoopExportImporter { r.recoveryScore = row.double("recovery_score_pct") r.restingHeartRate = row.double("resting_heart_rate_bpm", "resting_heart_rate") r.hrvMs = row.double("heart_rate_variability_ms", "heart_rate_variability_rmssd_ms") - r.skinTempCelsius = row.double("skin_temp_celsius", "skin_temp_f") + // #1849: a Fahrenheit WHOOP export ships `skin_temp_f`, NOT `skin_temp_celsius`. Treating + // the two as aliases stored the °F value unconverted (92.3 °F → 92.3 in a °C column), + // which read as a lethal fever and poisoned the baseline. Read each as its OWN key and + // convert the Fahrenheit value on the way in. `skin_temp_celsius` wins when both are + // present (a Celsius export is the canonical form). + if let c = row.double("skin_temp_celsius") { + r.skinTempCelsius = c + } else if let f = row.double("skin_temp_f") { + r.skinTempCelsius = (f - 32.0) * 5.0 / 9.0 + } r.bloodOxygenPct = row.double("blood_oxygen_pct", "blood_oxygen_pct_pct") r.dayStrain = row.double("day_strain") r.energyKcal = row.double("energy_burned_cal") // CSV "(cal)" == kcal diff --git a/Packages/StrandImport/Tests/StrandImportTests/AppleHealthImporterTests.swift b/Packages/StrandImport/Tests/StrandImportTests/AppleHealthImporterTests.swift index c420275835..13d50e5452 100644 --- a/Packages/StrandImport/Tests/StrandImportTests/AppleHealthImporterTests.swift +++ b/Packages/StrandImport/Tests/StrandImportTests/AppleHealthImporterTests.swift @@ -1,4 +1,8 @@ import XCTest +#if canImport(FoundationXML) +// See the source files: corelibs keeps XMLParser in its own module. +import FoundationXML +#endif @testable import StrandImport final class AppleHealthImporterTests: XCTestCase { diff --git a/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift b/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift index 40f08c789b..49bffa8885 100644 --- a/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift +++ b/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift @@ -183,4 +183,139 @@ final class HealthWritebackTests: XCTestCase { XCTAssertEqual(plan[0].allKeyStartTs, [t, t + 600]) XCTAssertEqual(plan[0].intervals, [.init(start: t + 600, end: t + 7_200, kind: .unspecified)]) } + + // MARK: - Apple Health external UUID keys (#1503) + + /// The key must NOT embed a device id — the active strap id is not durable and embedding it + /// stranded every prior record in Apple Health as unreachable duplicates after a re-pair. + func testVitalKeyHasNoDeviceIdSegment() { + let key = HealthWriteback.appleHealthVitalKey(metricId: "restingHeartRate", day: "2026-08-27") + XCTAssertEqual(key, "noop:restingHeartRate:2026-08-27") + // The key must be STABLE — no strap id, no hash, nothing that changes on a re-pair. + XCTAssertFalse(key.contains("my-whoop"), + "the vital key must not embed a device id (#1503)") + } + + func testSleepKeyHasNoDeviceIdSegment() { + let startTs = 1_700_000_000 + let key = HealthWriteback.appleHealthSleepKey(startTs: startTs) + XCTAssertEqual(key, "noop:sleep:1700000000") + XCTAssertFalse(key.contains("my-whoop"), + "the sleep key must not embed a device id (#1503)") + } + + func testWorkoutKeyHasNoDeviceIdSegment() { + let startTs = 1_700_000_000 + let key = HealthWriteback.appleHealthWorkoutKey(startTs: startTs) + XCTAssertEqual(key, "noop:workout:1700000000") + XCTAssertFalse(key.contains("my-whoop"), + "the workout key must not embed a device id (#1503)") + } + + /// The key is deterministic: the same inputs always produce the same key, so the + /// delete-then-write reconciliation can find prior records across app restarts and strap + /// lifecycle changes. + func testKeysAreDeterministic() { + XCTAssertEqual( + HealthWriteback.appleHealthVitalKey(metricId: "heartRateVariabilitySDNN", day: "2026-01-01"), + HealthWriteback.appleHealthVitalKey(metricId: "heartRateVariabilitySDNN", day: "2026-01-01")) + XCTAssertEqual( + HealthWriteback.appleHealthSleepKey(startTs: 123), + HealthWriteback.appleHealthSleepKey(startTs: 123)) + XCTAssertEqual( + HealthWriteback.appleHealthWorkoutKey(startTs: 456), + HealthWriteback.appleHealthWorkoutKey(startTs: 456)) + } + + /// Different metrics on the same day produce different keys (no collision). + func testVitalKeysDifferByMetric() { + let day = "2026-08-27" + let rhr = HealthWriteback.appleHealthVitalKey(metricId: "restingHeartRate", day: day) + let hrv = HealthWriteback.appleHealthVitalKey(metricId: "heartRateVariabilitySDNN", day: day) + let spo2 = HealthWriteback.appleHealthVitalKey(metricId: "oxygenSaturation", day: day) + let resp = HealthWriteback.appleHealthVitalKey(metricId: "respiratoryRate", day: day) + XCTAssertNotEqual(rhr, hrv) + XCTAssertNotEqual(rhr, spo2) + XCTAssertNotEqual(rhr, resp) + XCTAssertNotEqual(hrv, spo2) + XCTAssertNotEqual(hrv, resp) + XCTAssertNotEqual(spo2, resp) + } + + // MARK: - #1503 stranded-records sweep completion tracking (per-type, not a single boolean) + + /// An authorized type that has not been swept is pending — it should be attempted this run. + func testStrandedSweepPendingForAuthorizedUnsweptType() { + let pending = HealthWriteback.strandedSweepPending( + swept: [], authorized: ["restingHeartRate", "sleepAnalysis"]) + XCTAssertEqual(pending, ["restingHeartRate", "sleepAnalysis"]) + } + + /// A type that was already swept is NOT pending again — the sweep is once per install per type. + func testStrandedSweepSkipsAlreadySweptType() { + let pending = HealthWriteback.strandedSweepPending( + swept: ["restingHeartRate"], authorized: ["restingHeartRate", "sleepAnalysis"]) + XCTAssertEqual(pending, ["sleepAnalysis"]) + } + + /// A type the user DECLINED (absent from authorized) is never pending — it is not attempted and + /// not marked swept, so a later grant still gets swept. This is the partial-authorization case. + func testStrandedSweepNeverAttemptsDeclinedType() { + let pending = HealthWriteback.strandedSweepPending( + swept: [], authorized: ["restingHeartRate"]) // workouts declined + XCTAssertFalse(pending.contains("workout")) + XCTAssertEqual(pending, ["restingHeartRate"]) + } + + /// A type whose delete SUCCEEDED this run is added to the swept set, so it is not retried. + func testStrandedSweepResultRecordsSuccessfulDelete() { + let updated = HealthWriteback.strandedSweepResult( + swept: ["restingHeartRate"], succeededThisRun: ["sleepAnalysis"]) + XCTAssertEqual(updated, ["restingHeartRate", "sleepAnalysis"]) + } + + /// A type whose delete FAILED (absent from succeededThisRun) is NOT added to the swept set, so + /// it is retried on the next write-back run instead of being silently abandoned. + func testStrandedSweepResultDoesNotRecordFailedDelete() { + let updated = HealthWriteback.strandedSweepResult( + swept: ["restingHeartRate"], succeededThisRun: []) // sleep delete threw + XCTAssertEqual(updated, ["restingHeartRate"]) + XCTAssertFalse(updated.contains("sleepAnalysis")) + } + + /// The partial-authorization recovery sequence: run 1 sweeps only the authorized type; after a + /// later grant, run 2 sweeps the newly-authorized type. The migration completes across runs. + func testStrandedSweepCompletesAcrossRunsAfterLateAuthorizationGrant() { + // Run 1: sleep granted, workouts declined. Only sleep is authorized. + var swept: Set = [] + let authRun1: Set = ["sleepAnalysis"] + let pending1 = HealthWriteback.strandedSweepPending(swept: swept, authorized: authRun1) + XCTAssertEqual(pending1, ["sleepAnalysis"]) + // Sleep delete succeeds; workouts were never attempted (declined). + swept = HealthWriteback.strandedSweepResult(swept: swept, succeededThisRun: ["sleepAnalysis"]) + XCTAssertEqual(swept, ["sleepAnalysis"]) + // Run 2: user later grants workouts. + let authRun2: Set = ["sleepAnalysis", "workout"] + let pending2 = HealthWriteback.strandedSweepPending(swept: swept, authorized: authRun2) + XCTAssertEqual(pending2, ["workout"], "the late-granted workout type must still be swept") + // Workout delete succeeds; the migration is now complete. + swept = HealthWriteback.strandedSweepResult(swept: swept, succeededThisRun: ["workout"]) + XCTAssertEqual(swept, ["sleepAnalysis", "workout"]) + } + + /// A delete that fails on run 1 is retried on run 2 and succeeds — the migration is not lost. + func testStrandedSweepRetriesFailedDeleteOnNextRun() { + var swept: Set = [] + let auth: Set = ["restingHeartRate", "sleepAnalysis"] + // Run 1: sleep delete throws (absent from succeededThisRun). + let pending1 = HealthWriteback.strandedSweepPending(swept: swept, authorized: auth) + XCTAssertEqual(pending1, ["restingHeartRate", "sleepAnalysis"]) + swept = HealthWriteback.strandedSweepResult(swept: swept, succeededThisRun: ["restingHeartRate"]) + XCTAssertEqual(swept, ["restingHeartRate"]) + // Run 2: sleep is still pending (not marked swept), so it is retried and succeeds. + let pending2 = HealthWriteback.strandedSweepPending(swept: swept, authorized: auth) + XCTAssertEqual(pending2, ["sleepAnalysis"], "the failed sleep delete must be retried") + swept = HealthWriteback.strandedSweepResult(swept: swept, succeededThisRun: ["sleepAnalysis"]) + XCTAssertEqual(swept, ["restingHeartRate", "sleepAnalysis"]) + } } diff --git a/Packages/StrandImport/Tests/StrandImportTests/Resources/physiological_cycles_fahrenheit.csv b/Packages/StrandImport/Tests/StrandImportTests/Resources/physiological_cycles_fahrenheit.csv new file mode 100644 index 0000000000..f01e289ac8 --- /dev/null +++ b/Packages/StrandImport/Tests/StrandImportTests/Resources/physiological_cycles_fahrenheit.csv @@ -0,0 +1,2 @@ +Cycle start time,Cycle end time,Cycle timezone,Recovery score %,Resting heart rate (bpm),Heart rate variability (ms),Skin temp (f),Blood oxygen %,Day Strain,Energy burned (cal),Max HR (bpm),Average HR (bpm),Sleep onset,Wake onset,Sleep performance %,Respiratory rate (rpm),Asleep duration (min),In bed duration (min),Light sleep duration (min),Deep (SWS) duration (min),REM duration (min),Awake duration (min),Sleep efficiency %,Sleep consistency %,Sleep need (min),Sleep debt (min) +2024-01-02 06:30:00,2024-01-03 06:29:00,UTC+01:00,72,52,68.4,91.58,96.0,12.5,2450,165,68,2024-01-01 23:15:00,2024-01-02 06:30:00,85,14.2,420,455,210,95,115,35,92.3,88.0,480,60 diff --git a/Packages/StrandImport/Tests/StrandImportTests/WhoopExportImporterTests.swift b/Packages/StrandImport/Tests/StrandImportTests/WhoopExportImporterTests.swift index 7e6aa6d3cf..f1e86a9444 100644 --- a/Packages/StrandImport/Tests/StrandImportTests/WhoopExportImporterTests.swift +++ b/Packages/StrandImport/Tests/StrandImportTests/WhoopExportImporterTests.swift @@ -94,6 +94,36 @@ final class WhoopExportImporterTests: XCTestCase { XCTAssertEqual(r1.recoveryScore, 55) } + // MARK: - #1849: Fahrenheit skin-temp import + + /// A Fahrenheit WHOOP export ships `skin_temp_f` instead of `skin_temp_celsius`. The + /// importer must convert the °F value to °C on the way in — NOT store it unconverted + /// (92.3 °F stored as 92.3 in a °C column reads as a lethal fever and poisons the + /// baseline). 91.58 °F = 33.1 °C, the same value the Celsius fixture carries. + func testFahrenheitSkinTempIsConvertedToCelsius() throws { + let table = CSVTable(data: Fixtures.data("physiological_cycles_fahrenheit.csv")) + let rows = WhoopExportImporter().parseCycles(table) + + XCTAssertEqual(rows.count, 1) + let r0 = try XCTUnwrap(rows.first) + // 91.58 °F → (91.58 − 32) × 5/9 = 33.1 °C — the same stored value the Celsius + // fixture produces, so a Fahrenheit import lands byte-identical to a Celsius one. + XCTAssertEqual(r0.skinTempCelsius ?? -999, 33.1, accuracy: 0.001, + "a Fahrenheit skin_temp_f must be converted to °C on import, not stored as-is") + } + + /// A Celsius export (the canonical form) is unaffected — `skin_temp_celsius` is read + /// directly and no conversion is applied. This is the regression guard: the fix must + /// not change the Celsius path. + func testCelsiusSkinTempIsUnchanged() throws { + let table = CSVTable(data: Fixtures.data("physiological_cycles.csv")) + let rows = WhoopExportImporter().parseCycles(table) + + let r0 = try XCTUnwrap(rows.first) + XCTAssertEqual(r0.skinTempCelsius ?? -999, 33.1, accuracy: 0.001, + "a Celsius skin_temp_celsius must pass through unchanged") + } + // MARK: - workouts.csv WITHOUT GPS columns func testWorkoutsWithoutGPSColumnsStillParse() throws { diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/BatteryPackInfo.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/BatteryPackInfo.swift index 745da92c2a..f203fb5ec3 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/BatteryPackInfo.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/BatteryPackInfo.swift @@ -28,36 +28,92 @@ public enum BatteryPackInfo { /// read via GET_EXTENDED_BATTERY_INFO (98) which reports voltage, NOT a charge %. nil on 5/MG. public let voltageMv: Int? + + /// Whether this reading is safe to SHOW. + /// + /// The offsets here are an unvalidated candidate re-derived from two captures, so a wrong one + /// would not fail — it would render a confident wrong number, which is the failure this project + /// treats as worse than a blank. A fuel gauge is a percentage: anything outside 0...100 means the + /// offset moved, and the caller must render nothing rather than the value. + /// + /// The fork that first shipped this hit exactly that shape from the other direction, reading a + /// 24,881 mV "cell voltage" off a 5/MG frame — a 4.0-path field read on a family that does not + /// answer it. A gauge that cannot be sanity-checked will eventually be believed. + public var displayable: Bool { + guard present, let soc = socPct else { return false } + return soc >= 0 && soc <= 100 + } + public init(present: Bool, socPct: Double?, serial: String?, btAddr: String?, voltageMv: Int? = nil) { self.present = present; self.socPct = socPct; self.serial = serial self.btAddr = btAddr; self.voltageMv = voltageMv } } + /// Offset of the present flag inside the event-109 payload. + public static let eventRecordBase = 4 + + /// Where a WHOOP 5/MG EVENT frame's opaque payload begins. Mirrors `Framing.decodeEventWhoop5`. + public static let whoop5EventPayloadStart = 16 + + /// The uncatalogued 5/MG event that carries the pack record. Named here because `EventNumber` has no + /// entry for it — it decodes as `0x6D(109)`. + public static let packInfoEvent = 109 + + /// Decode the pack RECORD itself, given the offset of its present-flag byte. + /// + /// Split out from `decode` because the same record reaches us by two entirely different transports: as + /// the payload of a `GET_BATTERY_PACK_INFO` (151) COMMAND_RESPONSE, and — hardware-confirmed on WHOOP MG + /// fw 50.39.1.0, 2026-09-01 — as the payload of the UNCATALOGUED pushed event `0x6D` (109), which the + /// strap volunteers while a pack is attached. Only the framing around the record differs; the fields are + /// identical, so they must not be decoded twice. + /// + /// Layout from `base`: +0 present, +1 BT address (6 B), +7 serial (16 B ASCII, NUL-terminated), + /// +23 SoC (u16 little-endian, tenths of a percent). nil when the buffer is too short to hold it. + public static func decodeRecord(_ bytes: [UInt8], base: Int) -> Info? { + guard base >= 0, bytes.count > base else { return nil } + let present = bytes[base] == 1 + guard present else { return Info(present: false, socPct: nil, serial: nil, btAddr: nil) } + + let btStart = base + 1 + let serStart = base + 7 + let socStart = base + 23 + guard bytes.count >= socStart + 2 else { return nil } + + let btAddr = bytes[btStart.. Info? { + decodeRecord(payload, base: eventRecordBase) + } + + /// Decode straight from a whole 5/MG EVENT frame, so callers never need to know where the payload + /// starts. Callers must first check the event number is `packInfoEvent`; this does not re-check it, + /// because the event byte lives in the frame header rather than the record. + public static func decodeEventFrame(_ frame: [UInt8]) -> Info? { + decodeRecord(frame, base: whoop5EventPayloadStart + eventRecordBase) + } + /// The response-command byte sits at `cmdOff` (10 on WHOOP 5/MG — the only family with a pack; a 4.0's /// 6 is accepted only so a caller can pass it, though 4.0 never answers 151). Returns nil when the /// frame is not a well-formed 151 SUCCESS response. The caller is expected to have CRC-gated the frame /// (the framing layer already does), as the sibling probes assume. + /// + /// NOTE this command path is retained for completeness only. On WHOOP MG fw 50.39.1.0 opcode 151 answers + /// FAILURE(0) whether or not a pack is attached, so the feature is driven by `decodeEventFrame` instead. public static func decode(frame: [UInt8], cmdOff: Int = 10) -> Info? { - // Layout relative to cmdOff, pinned to the captures: +0 resp-cmd (151), +2 result (1 = SUCCESS), - // +4 present flag, +5 BT address (6 bytes), +11 serial (16-byte ASCII, NUL-terminated), - // +27 SoC (u16 little-endian, tenths of a percent). + // Header, pinned to the captures: +0 resp-cmd (151), +2 result (1 = SUCCESS). The record then starts + // at +4, which is what `decodeRecord` expects as its base. guard cmdOff >= 0, frame.count > cmdOff + 4 else { return nil } guard Int(frame[cmdOff]) == 151, Int(frame[cmdOff + 2]) == 1 else { return nil } - let present = frame[cmdOff + 4] == 1 - guard present else { return Info(present: false, socPct: nil, serial: nil, btAddr: nil) } - - let btStart = cmdOff + 5 - let serStart = cmdOff + 11 - let socStart = cmdOff + 27 - guard frame.count >= socStart + 2 else { return nil } - - let btAddr = frame[btStart..= 0, behind <= t else { return nil } return behind } + + /// The BLE seam's decision on a GET_DATA_RANGE COMMAND_RESPONSE: is this a reply to `opcode`, and + /// may it move state? A reply is accepted only on the verifier's FULL verdict. + /// + /// This one reply does not merely report a timestamp: `newestUnix`/`oldestUnix` become the window + /// every drained record of the same sync is checked against (#547). A damaged reply that narrows + /// the window makes the real records fall through it — the section persists nothing and is acked + /// anyway, which is the permanent loss the integrity gate exists to stop. So the gate belongs to + /// this decision, not to the seam that happens to host it: as a predicate it is testable off the + /// app target, where nothing compiles the seam. + /// + /// `verdictOK` is the FULL verdict of the single parse the caller already holds, taken lazily so a + /// frame that is not this reply costs one byte compare and no parse. The opcode is passed in rather + /// than restated here: the command table lives with the caller. + public static func acceptsReply(_ frame: [UInt8], cmdOff: Int, opcode: UInt8, + verdictOK: @autoclosure () -> Bool) -> Bool { + guard cmdOff >= 0, cmdOff < frame.count, frame[cmdOff] == opcode else { return false } + return verdictOK() + } + + /// True when a GET_DATA_RANGE COMMAND_RESPONSE is the `PENDING(2)` acknowledgement rather than the + /// answer. The strap replies twice: a short PENDING ack, then the payload with `SUCCESS(1)`. Framing's + /// own result-code table already states it — "2=PENDING precedes SUCCESS on GET_DATA_RANGE + /// (hardware-confirmed, #78 fork)" — but `pagesBehind` had no way to tell the two apart, so the ack + /// (which carries no ring pointers by construction) decoded to nil and logged as a decode FAILURE once + /// per sync. + /// + /// The result byte sits at `cmdOff + 2`, after the echoed opcode and the origin sequence. A frame too + /// short to hold one is not a PENDING ack, so it stays false and the caller keeps its existing + /// too-short handling. + public static func isPendingResponse(_ frame: [UInt8], cmdOff: Int) -> Bool { + guard cmdOff >= 0, cmdOff + 2 < frame.count else { return false } + return frame[cmdOff + 2] == 2 + } } diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/FrameDiagnostics.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/FrameDiagnostics.swift new file mode 100644 index 0000000000..2215082b73 --- /dev/null +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/FrameDiagnostics.swift @@ -0,0 +1,138 @@ +import Foundation + +// Diagnosis for the frame-integrity gate: what a rejected frame still tells us, how often each reason +// occurred, and how a frame is rendered for a human. All pure and app-free, so the counters and the +// inspector's line are covered by `swift test` rather than only by running the tool. + +public extension ParsedFrame { + /// Whether the decoder got a PACKET TYPE out of this frame — the parseability question, which is + /// NOT the integrity question `ok` answers. + /// + /// The two used to be the same value, so every diagnostic surface that wanted "did this decode?" + /// read `ok`. Now `ok` means "header checksum, payload CRC32 and structural length all agree", and + /// reading it as parseability would blank the packet type of exactly the frames a capture exists to + /// map: a frame with a flipped header byte still decodes its type, its sequence number and its + /// fields, and losing that in the record is losing the evidence. + /// + /// True when the parse produced a real type name rather than the `INVALID/FRAGMENT` placeholder the + /// parsers return for a byte run below the family minimum or without a start-of-frame byte. + var isParsable: Bool { typeName != ParsedFrame.unparsableTypeName } + + /// The placeholder type name both parsers use for a byte run they could not read at all. + static var unparsableTypeName: String { "INVALID/FRAGMENT" } + + /// True for the frame class that passed the gates BEFORE this change: the payload CRC32 verified, + /// but the envelope did not — a wrong header checksum, a declared length below the family minimum, + /// a truncated frame or one with trailing bytes. + /// + /// It is deliberately read off the parse result and needs no second verification: both values are + /// already on the frame the consumer was handed. + var payloadCRCOKButEnvelopeRejected: Bool { !ok && crcOK == true } +} + +/// Per-reason rejection counters for one connection, plus the ONE extra named counter the hardware run +/// is read from. +/// +/// Why a plain per-reason count is not enough (D3): the abort criterion for the hardware run is a +/// CONJUNCTION — "header checksum or length wrong WHILE the payload CRC32 verifies" — and no single +/// reason bucket can express it. The length bucket in particular also collects the harmless resyncs +/// after a lost notification, which were happening before this change too. So +/// `payloadCRCOKButEnvelopeRejected` is counted separately; it is the class that used to pass, and thus +/// the cleanest regression signal. +/// +/// Counted per reason only — NOT additionally per device family. A connection talks to exactly one +/// strap, so a family dimension would be constant. +/// +/// A tally asserts only what it observed: an unverifiable payload CRC32 is counted as +/// `payloadCRCUnverifiable`, never folded into `payloadCRCMismatch`. "Could not be checked" is not +/// "was wrong". +public struct FrameRejectTally: Equatable, Sendable { + /// How often each reason was the FIRST rule a frame failed. `.none` is never counted. + public private(set) var counts: [FrameRejectReason: Int] = [:] + + /// The named class from D3: envelope rejected while the payload CRC32 verified. + public private(set) var payloadCRCOKButEnvelopeRejected = 0 + + /// The reassembler's monotonic drop count already folded into `.belowMinimumLength`, so repeated + /// folds of the same counter cannot double-count. + private var absorbedReassemblerDrops = 0 + + public init() {} + + /// Count one parse result. Intact frames are ignored, so this can sit on the frame path unguarded. + /// Returns the reason recorded, or `.none` when the frame was intact and nothing was counted. + @discardableResult + public mutating func note(_ parsed: ParsedFrame) -> FrameRejectReason { + guard !parsed.ok else { return .none } + let reason = parsed.rejectReason + counts[reason, default: 0] += 1 + if parsed.payloadCRCOKButEnvelopeRejected { payloadCRCOKButEnvelopeRejected += 1 } + return reason + } + + /// Count one frame from the VERIFIER's result rather than from a parse result. + /// + /// The offload path does not parse its frames at the BLE seam — it hands them straight to the + /// Backfiller — so the parse-result overload above never sees them, and the named counter the + /// hardware run's abort criterion is read from would stay at zero for exactly the traffic in which + /// the permanent loss occurs. `FrameCheck` already carries the verdict, the reason and the payload + /// CRC32 outcome, so this counts what was observed and costs no second parse. + /// + /// It asserts no more than the parse-result overload: `payloadCRCOKButEnvelopeRejected` is raised + /// only when the CRC32 was actually computed and verified, never when it could not be checked. + @discardableResult + public mutating func note(_ check: FrameCheck) -> FrameRejectReason { + guard !check.ok else { return .none } + counts[check.reason, default: 0] += 1 + if check.crc32OK == true { payloadCRCOKButEnvelopeRejected += 1 } + return check.reason + } + + /// Fold a reassembler's `belowMinimumLengthDrops` into the `.belowMinimumLength` bucket. + /// + /// A byte run the reassembler drops never reaches a parser — and never reaches the evidence- + /// preserving reader either — so without this it would vanish with no trace at all. The argument is + /// the reassembler's MONOTONIC total; only the growth since the last fold is added, so calling this + /// once per notification is correct and idempotent. + public mutating func absorbReassemblerDrops(_ monotonicTotal: Int) { + guard monotonicTotal > absorbedReassemblerDrops else { return } + counts[.belowMinimumLength, default: 0] += monotonicTotal - absorbedReassemblerDrops + absorbedReassemblerDrops = monotonicTotal + } + + /// How often `reason` was recorded. + public func count(_ reason: FrameRejectReason) -> Int { counts[reason] ?? 0 } + + /// Every rejection counted, across all reasons. + public var totalRejected: Int { counts.values.reduce(0, +) } + + /// One line naming every reason that actually occurred, in a stable order, or nil when nothing was + /// rejected — a per-connection readout, so its caller gates it behind the Test Centre domain. + /// Silence when there is nothing to report; no "0 rejections" line to read past. + public func summaryLine() -> String? { + guard totalRejected > 0 else { return nil } + let parts = FrameRejectReason.allCases + .filter { $0 != .none && count($0) > 0 } + .map { "\($0.rawValue)=\(count($0))" } + return "frameReject total=\(totalRejected) " + parts.joined(separator: " ") + + " payloadCRCOKButEnvelopeRejected=\(payloadCRCOKButEnvelopeRejected)" + } +} + +/// The inspector's one-line rendering of a parsed frame. +/// +/// It lives in the library rather than in the command-line tool so a test can pin it: the tool is an +/// executable target with no test target of its own, and the property that matters — a REJECTED frame +/// still shows its packet type, and shows why it was rejected — is exactly the kind that goes quiet +/// unnoticed. +/// +/// `index` and `family` are provenance the caller holds; everything else comes off the parse result. +public func frameInspectionLine(index: Int, family: DeviceFamily, parsed: ParsedFrame) -> String { + let crc = parsed.crcOK.map { $0 ? "ok" : "BAD" } ?? "—" + var line = "[\(index)] \(family.rawValue) ok=\(parsed.ok) type=\(parsed.typeName)" + line += " seq=\(parsed.seq.map(String.init) ?? "—") crc=\(crc)" + // Only on a rejection, and only the reason the verifier actually reported: an intact frame carries + // no reason, and printing `reason=none` on every line would train the eye to skip the field. + if !parsed.ok { line += " reason=\(parsed.rejectReason.rawValue)" } + return line +} diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/Framing.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/Framing.swift index 4cd03202ea..011333b599 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/Framing.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/Framing.swift @@ -82,16 +82,84 @@ public func crc16Modbus(_ bytes: [UInt8], _ from: Int = 0, _ to: Int? = nil) -> return crc } +/// Why a frame failed the envelope check — one value, never optional, so a consumer can report the +/// cause without verifying or parsing the frame a second time (the parse-once invariant). +/// +/// `none` is the ONLY value that accompanies a positive verdict. The two payload-CRC cases are kept +/// apart on purpose: a diagnostic may only assert what it observed, and "we could not compute the +/// CRC32" is not the same claim as "the CRC32 disagreed". +public enum FrameRejectReason: String, Codable, Equatable, Sendable, CaseIterable { + /// The frame is intact: header checksum, payload CRC32 and the structural length all agree. + case none + /// No 0xAA start-of-frame byte — this byte run is not a frame at all. + case noStartOfFrame + /// Fewer bytes than the device family's smallest well-formed frame can have. + case belowMinimumLength + /// The byte count does not equal the total derived from the declared length field: the frame is + /// truncated, or it carries trailing bytes past its own end. + case lengthMismatch + /// The header checksum (CRC-8 on WHOOP 4.0, CRC-16-Modbus on WHOOP 5.0/MG) disagreed. + case headerChecksumMismatch + /// The payload CRC32 was computed and disagreed. + case payloadCRCMismatch + /// The payload CRC32 could not be computed at all. Fail-closed: an unverifiable CRC counts as a + /// negative verdict, never as "unknown". The structural rules above make this unreachable for a + /// frame that got past them, so it is defence in depth rather than an observed class. + case payloadCRCUnverifiable +} + +/// The structural lower bounds a frame must clear before any of its bytes are read as fields. +/// +/// WHOOP 4.0: `[SOF][len u16][crc8][type][seq][cmd] + >=1 payload byte + [crc32 u32]` = 11 bytes. +/// WHOOP 5.0/MG: `[SOF][fmt][declLen u16][hdr u16][crc16 u16] + >=1 payload byte + [crc32 u32]` = 13. +/// Both are the values `E2` fixes; the smallest real frame in the project's captures is exactly 11 +/// bytes (WHOOP 4.0) and 124 bytes (WHOOP 5.0/MG), so neither bound rejects a recorded frame. +public enum FrameLimits { + public static let whoop4MinimumFrameBytes = 11 + public static let whoop5MinimumFrameBytes = 13 + + /// The minimum total frame size for `family`, in bytes. + public static func minimumFrameBytes(for family: DeviceFamily) -> Int { + switch family { + case .whoop4: return whoop4MinimumFrameBytes + case .whoop5: return whoop5MinimumFrameBytes + } + } +} + public struct FrameCheck: Equatable { + /// The FULL verdict: start-of-frame, minimum length, exact length, header checksum and payload + /// CRC32 together. True only when `reason == .none`. public let ok: Bool public let length: Int? public let crc8OK: Bool? public let crc32OK: Bool? - public init(ok: Bool, length: Int? = nil, crc8OK: Bool? = nil, crc32OK: Bool? = nil) { + /// Why `ok` is false; `.none` exactly when `ok` is true. + public let reason: FrameRejectReason + public init(ok: Bool, length: Int? = nil, crc8OK: Bool? = nil, crc32OK: Bool? = nil, + reason: FrameRejectReason = .none) { self.ok = ok self.length = length self.crc8OK = crc8OK self.crc32OK = crc32OK + self.reason = reason + } +} + +/// Turn the individual outcomes into ONE verdict and ONE reason, in a fixed order: structure first +/// (a length we cannot trust makes every later read meaningless), then the header checksum, then the +/// payload CRC32. `crc32OK == nil` is a rejection, not an "unknown" that a consumer might read as a +/// pass — that tri-state is exactly what let malformed frames drive live state before this change. +@inline(__always) +private func frameRejectReason(totalFromLength: Int, actualCount: Int, minimumBytes: Int, + headerCRCOK: Bool, crc32OK: Bool?) -> FrameRejectReason { + if totalFromLength < minimumBytes { return .belowMinimumLength } + if totalFromLength != actualCount { return .lengthMismatch } + if !headerCRCOK { return .headerChecksumMismatch } + switch crc32OK { + case .none: return .payloadCRCUnverifiable + case .some(false): return .payloadCRCMismatch + case .some(true): return .none } } @@ -106,23 +174,39 @@ private func u32le(_ bytes: [UInt8], _ off: Int) -> UInt32 { | (UInt32(bytes[off + 2]) << 16) | (UInt32(bytes[off + 3]) << 24) } -/// Validate a complete frame envelope and both CRCs. +/// Validate a complete frame envelope: structure, header checksum and payload CRC32 together. /// Frame: [0xAA][len u16 LE][crc8(len)][...inner...][crc32 u32 LE], total = len + 4. +/// +/// A frame is accepted only when it is at least `FrameLimits.whoop4MinimumFrameBytes` long, carries +/// EXACTLY `len + 4` bytes (so a truncated frame and one with trailing bytes are both rejected), its +/// CRC-8 over the length field matches, and its CRC32 over the inner record matches. The individual +/// outcomes stay on the result as diagnostics; `reason` says which rule failed first. public func verifyFrame(_ frame: [UInt8]) -> FrameCheck { - if frame.count < 8 || frame[0] != 0xAA { - return FrameCheck(ok: false) + guard frame.first == 0xAA else { + return FrameCheck(ok: false, reason: .noStartOfFrame) + } + guard frame.count >= FrameLimits.whoop4MinimumFrameBytes else { + // Too short even to hold an envelope plus one payload byte: no field of it is read, and the + // length word it may carry is not worth reporting as a length. + return FrameCheck(ok: false, reason: .belowMinimumLength) } let length = u16le(frame, 1) + let total = length + 4 // Ranged CRCs checksum the frame in place, with no per-frame sub-array allocation. let crc8OK = crc8(frame, 1, 3) == frame[3] var crc32OK: Bool? = nil - // length must cover at least the envelope's inner bytes (mirrors framing.py). - if 7 <= length && length + 4 <= frame.count { + // length must cover at least the envelope's inner bytes (mirrors framing.py). Trailing bytes do + // not stop the CRC32 from being computed — that combination (payload CRC right, envelope wrong) + // is precisely the class this change stops admitting, so it must stay observable. + if 7 <= length && total <= frame.count { // inner record = frame[4.. FrameCheck { } private func verifyFrameWhoop5(_ frame: [UInt8]) -> FrameCheck { - // Minimum whoop5 frame: 8 header bytes (incl. CRC16) + 4 CRC32 trailer = 12. - if frame.count < 12 || frame[0] != 0xAA { - return FrameCheck(ok: false) + guard frame.first == 0xAA else { + return FrameCheck(ok: false, reason: .noStartOfFrame) } - let declaredLength = u16le(frame, 2) - // declaredLength counts payload + the 4-byte CRC32 trailer (mirrors Goose v5Frames/v5Payload). - guard declaredLength >= 4 else { - return FrameCheck(ok: false, length: declaredLength) + // Smallest well-formed whoop5 frame: 8 header bytes (incl. CRC16) + 1 payload byte + 4 CRC32. + guard frame.count >= FrameLimits.whoop5MinimumFrameBytes else { + return FrameCheck(ok: false, reason: .belowMinimumLength) } + // declaredLength counts payload + the 4-byte CRC32 trailer (mirrors Goose v5Frames/v5Payload). + let declaredLength = u16le(frame, 2) let total = declaredLength + 8 // Header CRC16-Modbus over the first 6 bytes, stored LE at frame[6..8]. Ranged, no copy. @@ -168,8 +252,8 @@ private func verifyFrameWhoop5(_ frame: [UInt8]) -> FrameCheck { let headerCRCOK = wantHeaderCRC == gotHeaderCRC var crc32OK: Bool? = nil - if frame.count >= total { - // payload spans [8, total-4); CRC32 trailer is the final 4 bytes of the frame. + if declaredLength >= 4, total <= frame.count { + // payload spans [8, total-4); CRC32 trailer is the final 4 bytes the length declares. let payloadEnd = total - 4 // payload = frame[8.. FrameCheck { crc32OK = want == got } - let ok = headerCRCOK && (crc32OK ?? false) + let reason = frameRejectReason(totalFromLength: total, actualCount: frame.count, + minimumBytes: FrameLimits.whoop5MinimumFrameBytes, + headerCRCOK: headerCRCOK, crc32OK: crc32OK) // Report the header outcome through crc8OK so callers have a single header-CRC signal. - return FrameCheck(ok: ok, length: declaredLength, crc8OK: headerCRCOK, crc32OK: crc32OK) + return FrameCheck(ok: reason == .none, length: declaredLength, crc8OK: headerCRCOK, + crc32OK: crc32OK, reason: reason) } /// Reconstruct a complete frame from a bare payload (data == frame[7:]). -/// Some captures store only the data portion; rebuild the envelope with a correct zlib -/// crc32 and a placeholder crc8 byte (0x00). Mirrors framing.py frame_from_payload. +/// Some captures store only the data portion; rebuild the envelope with a correct zlib crc32 AND a +/// correct CRC-8 header checksum. Mirrors framing.py frame_from_payload. +/// +/// The CRC-8 byte used to be a 0x00 placeholder. That was harmless while the gates only asked +/// whether the payload CRC32 was *demonstrably* wrong, but a frame built this way is rejected the +/// moment the header checksum counts — so every rebuilt frame is now a frame a strap could have +/// sent, and the round-trip through `verifyFrame` is positive end to end. public func frameFromPayload(_ data: [UInt8], type: UInt8, seq: UInt8 = 0, cmd: UInt8 = 0) -> [UInt8] { let inner: [UInt8] = [type, seq, cmd] + data let length = inner.count + 4 - var frame: [UInt8] = [0xAA, UInt8(length & 0xFF), UInt8((length >> 8) & 0xFF), 0x00] + var frame: [UInt8] = [0xAA, UInt8(length & 0xFF), UInt8((length >> 8) & 0xFF)] + frame.append(crc8(frame, 1, 3)) frame.append(contentsOf: inner) let c = crc32(inner) frame.append(UInt8(c & 0xFF)) @@ -228,8 +321,9 @@ public func puffinCommandFrame(cmd: UInt8, seq: UInt8, payload: [UInt8] = [0x00] } /// Accumulate BLE notification fragments into complete frames. -/// A complete frame is `length + 4` bytes where length = u16 LE at buf[1..3]. -/// Mirrors framing.py Reassembler. +/// A complete frame is `length + 4` bytes where length = u16 LE at buf[1..3], and never smaller than +/// the family minimum in `FrameLimits` — a start-of-frame declaring less is dropped and the stream +/// resyncs on the next one. Mirrors framing.py Reassembler. public final class Reassembler { // Backed by a flat byte buffer plus a read cursor rather than draining off the front. The earlier // form called buf.removeFirst(n), which shifts the whole tail down on every completed frame, so @@ -247,6 +341,12 @@ public final class Reassembler { /// Mirrors the Android `Framing.kt` cap. (Reimplemented from @vulnix0x4's PR #374.) static let maxFrameBytes = 8192 + /// How many start-of-frame bytes were dropped because the total length they declared was below + /// the family minimum. Such a byte run never reaches a parser, so without this counter it would + /// vanish without trace — and one of the readers downstream exists to preserve exactly the + /// frames nothing else can read. Monotonic for the lifetime of the reassembler. + public private(set) var belowMinimumLengthDrops = 0 + /// `family` selects the frame-length convention: /// - WHOOP 4.0 reads a u16 length at `buf[1..3]`, total = `length + 4`. /// - WHOOP 5.0 ("puffin") reads a u16 declared length at `buf[2..4]` (after the `0xAA` SOF and @@ -282,6 +382,14 @@ public final class Reassembler { case .whoop5: total = (Int(buf[head + 2]) | (Int(buf[head + 3]) << 8)) + 8 } + if total < FrameLimits.minimumFrameBytes(for: family) { + // A declared total below the family minimum cannot be a frame: emitting it would hand + // a parser a byte run whose "inner fields" are its own checksum trailer. Drop this + // 0xAA, count it, and resync on the next one — same shape as the ceiling below. + belowMinimumLengthDrops += 1 + head += 1 + continue + } if total > Reassembler.maxFrameBytes { // Impossibly large declared length → this 0xAA is garbage. Drop it and resync to the // next SOF instead of stalling the live stream until a reconnect. diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/HistoricalMeta.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/HistoricalMeta.swift index 677521442c..feec33d453 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/HistoricalMeta.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/HistoricalMeta.swift @@ -18,9 +18,15 @@ public enum HistoricalMeta: Equatable { /// - For HISTORY_END the metadata post-hook additionally stores in p.parsed: /// `"unix"` → `.int(unix_seconds)` and `"trim_cursor"` → `.int(trim_value)` public func classifyHistoricalMeta(_ p: ParsedFrame) -> HistoricalMeta { - // Integrity gate: only act on a checksum-valid frame. CRC32 is the protocol's only integrity - // check; without this a malicious/garbled BLE peer could forge HISTORY_END/HISTORY_COMPLETE - // (advancing the trim cursor + acking the strap to discard data we never durably stored). + // Integrity gate: only act on an INTACT frame. `ok` is now the verifier's FULL verdict — header + // checksum, payload CRC32 and the structural length together — which is the part that matters + // here: a forged HISTORY_END/HISTORY_COMPLETE whose payload CRC32 is right but whose header + // checksum or declared length is not used to reach this classifier, and acting on one advances the + // trim cursor, which acks the strap to discard data we never durably stored. + // + // The `crcOK` half of the old two-step check is now redundant for every freshly parsed frame and is + // kept only for parse results handed in from elsewhere — a `ParsedFrame` decoded from a capture + // written before the verdict widened carries the old constant `ok: true` beside a false `crcOK`. guard p.ok, p.crcOK != false else { return .other } guard p.typeName == "METADATA" else { return .other } guard case .string(let metaName)? = p.parsed["meta_type"] else { return .other } diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/HistoricalStreams.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/HistoricalStreams.swift index 398cf40094..e5f5256519 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/HistoricalStreams.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/HistoricalStreams.swift @@ -78,15 +78,31 @@ public func isPlausibleHistoricalUnix(_ ts: Int, wallNow: Int, /// archive they are lost forever while the UI reports a clean sync (#77 / #91). /// /// Console (type-50, `frame[typeIndex] == 0x32`) frames are strap-side debug-log text that decode to -/// zero rows BY DESIGN and are never returned. 5/MG v26 (raw PPG block, hist_version 26) is also -/// skipped unconditionally (even on a CRC failure): a v26 record's payload is the optical waveform, -/// which `extractHistoricalStreams` now persists durably in its OWN stream (`Streams.ppgWaveform` / -/// WhoopStore's `ppgWaveformSample` table, issue #156 follow-up) whenever it decodes — this reject -/// archive exists for genuinely-undecodable records, and a decoded v26 record was never one of those. +/// zero rows BY DESIGN and are never returned. 5/MG v26 (raw PPG block, hist_version 26) is skipped +/// only while it is INTACT: a v26 record's payload is the optical waveform, which +/// `extractHistoricalStreams` persists durably in its OWN stream (`Streams.ppgWaveform` / WhoopStore's +/// `ppgWaveformSample` table, issue #156 follow-up) — but only for a record it accepts. Since the +/// integrity gate, a v26 record with a broken envelope is dropped by that extraction too, so skipping +/// it here on the version byte alone would leave it stored nowhere while its section is acked anyway. /// Only genuine type-47 record frames whose payload would otherwise be silently dropped are returned. /// /// Used by the Backfiller/BLEManager to archive undecodable history BEFORE acking the trim. Mirrors /// the Android rejectedHistoricalRecords so one mapping toolchain re-ingests both archives. +/// +/// EVIDENCE-PRESERVING READER (D8) — its verdict runs the OTHER WAY ROUND, and the difference is +/// load-bearing. Everywhere else a negative integrity verdict means "do not act on this frame". Here it +/// means "this frame must be archived", because the strap is about to free it and this archive is the +/// only durable copy that will exist. Turning this into "act only on a positive verdict", the way the +/// state-driving gates were turned, would delete exactly the frames it exists to keep. +/// +/// So the stricter verdict makes the archived set LARGER, never smaller: every frame archived before is +/// archived still, plus the classes the envelope check now rejects (a wrong header checksum, a declared +/// length below the family minimum, a truncated frame, trailing bytes). The one way a frame can be lost +/// relative to before is upstream of here: the reassembler drops a byte run whose declared total is +/// below the family minimum, so it never reaches any parser. That drop is counted +/// (`Reassembler.belowMinimumLengthDrops`, folded into `FrameRejectTally`) precisely so it stays visible. +/// +/// Accepted side effect: on a noisy link the raw archives grow. The existing eviction rule bounds that. public func rejectedHistoricalRecords(_ rawFrames: [[UInt8]], family: DeviceFamily) -> [[UInt8]] { // The type byte sits at the inner-record start: frame[4] on WHOOP 4.0, frame[8] on WHOOP 5/MG // (the puffin envelope is 4 bytes longer). hist_version sits one byte past the type+seq+cmd @@ -97,7 +113,14 @@ public func rejectedHistoricalRecords(_ rawFrames: [[UInt8]], family: DeviceFami // Only genuine HISTORICAL_DATA records (47). Console (50) and METADATA frames have a // different type byte, so they never pass this gate — they are excluded by construction. guard f.count > typeIndex, Int(f[typeIndex]) == 47 else { return false } - if family == .whoop5, f.count > versionIndex, Int(f[versionIndex]) == 26 { return false } // v26 PPG: has its own durable stream (ppgWaveform), not this reject archive + // v26 PPG: skipped BECAUSE `extractHistoricalStreams` stores it durably in its own waveform + // stream (ppgWaveform) — so the skip holds only while that premise does. A REJECTED v26 record + // is dropped by the extraction like any other, which leaves it stored nowhere while the section + // is acked anyway. Bind the skip to the verdict, not to the version byte alone. + if family == .whoop5, f.count > versionIndex, Int(f[versionIndex]) == 26 { + let p = parseFrame(f, family: family) + return !(p.ok && p.crcOK != false) + } // UNMAPPED LAYOUT (5/MG) — archive UNCONDITIONALLY, whatever it decoded. // // The decode-outcome test below is the wrong question for a layout NOOP has no field map for. @@ -113,7 +136,11 @@ public func rejectedHistoricalRecords(_ rawFrames: [[UInt8]], family: DeviceFami // banks empty placeholder records at 1 Hz cannot push out the one informative frame either. if family == .whoop5, isUnmappedWhoop5HistoricalRecord(f) { return true } let p = parseFrame(f, family: family) - // Envelope/CRC reject: parse failed outright or the CRC32 trailer mismatched. + // NOT INTACT → ARCHIVE. Reading the verdict this way round is the whole point of this reader + // (D8): the frame cannot be turned into rows, so its bytes are the only thing left to keep. + // With the verdict widened to cover the header checksum and the structural length, this branch + // catches strictly MORE frames than it did before — which is the intended direction. The + // `crcOK` half stays for exactly that reason: every condition here can only add to the archive. if !p.ok || p.crcOK == false { return true } // Unmapped layout: the envelope parsed but no usable biometrics decoded. A record is genuinely // undecodable only if it has no timestamp, or NEITHER heart rate NOR motion. v25 (issue #30) @@ -242,6 +269,11 @@ public func extractHistoricalStreams(_ parsed: [ParsedFrame], // #891: packet types that reach `default:` and are dropped. See `Streams.unhandledPacketTypes`. var unhandledTypes: [String: Int] = [:] for r in parsed { + // `ok` is now the FULL verdict — header checksum, payload CRC32 and structural length — so it + // alone rejects everything the two-part check used to. The `crcOK` half is kept because this + // function takes parse results from its CALLER, and a `ParsedFrame` decoded from a capture file + // written before the verdict widened carries the old constant `ok: true` beside a false `crcOK`. + // Dropping it would start deriving rows from those. if !r.ok || r.crcOK == false { continue } let p = r.parsed switch r.typeName { diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/Interpreter.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/Interpreter.swift index aa5e8d6e01..2123d285cb 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/Interpreter.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/Interpreter.swift @@ -11,6 +11,9 @@ public struct DecodedField: Codable, Equatable { } public struct ParsedFrame: Codable, Equatable { + /// The FULL integrity verdict of `verifyFrame` — header checksum, payload CRC32 and structural + /// length together. It means "this frame is intact", NOT "this frame could be parsed": a frame + /// with a broken header still carries its decoded `typeName` and `parsed` fields for inspection. public let ok: Bool public let typeName: String public let seq: Int? @@ -20,45 +23,96 @@ public struct ParsedFrame: Codable, Equatable { public let rawHex: String public let fields: [DecodedField] public let parsed: [String: ParsedValue] + /// Why `ok` is false, carried here so a consumer can report the cause from the value it was + /// handed — the frame is parsed exactly once and the result threaded on, so a consumer that had + /// to verify again to learn the reason would break that invariant. + public let rejectReason: FrameRejectReason + + public init(ok: Bool, typeName: String, seq: Int?, cmdName: String?, crcOK: Bool?, + lenBytes: Int, rawHex: String, fields: [DecodedField], + parsed: [String: ParsedValue], rejectReason: FrameRejectReason = .none) { + self.ok = ok + self.typeName = typeName + self.seq = seq + self.cmdName = cmdName + self.crcOK = crcOK + self.lenBytes = lenBytes + self.rawHex = rawHex + self.fields = fields + self.parsed = parsed + self.rejectReason = rejectReason + } + + /// Decoding tolerates a MISSING `rejectReason` and defaults it to `.none`. A parse result is + /// serialised into capture files and read back from hand-written fixtures; a strictly required + /// new key would make every one of those older documents fail to decode. + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + ok = try c.decode(Bool.self, forKey: .ok) + typeName = try c.decode(String.self, forKey: .typeName) + seq = try c.decodeIfPresent(Int.self, forKey: .seq) + cmdName = try c.decodeIfPresent(String.self, forKey: .cmdName) + crcOK = try c.decodeIfPresent(Bool.self, forKey: .crcOK) + lenBytes = try c.decode(Int.self, forKey: .lenBytes) + rawHex = try c.decode(String.self, forKey: .rawHex) + fields = try c.decode([DecodedField].self, forKey: .fields) + parsed = try c.decode([String: ParsedValue].self, forKey: .parsed) + rejectReason = try c.decodeIfPresent(FrameRejectReason.self, forKey: .rejectReason) ?? .none + } } // MARK: - low-level readers (LE), nil when out of range (mirrors interpreter._read) - -@inline(__always) private func readU8(_ f: [UInt8], _ off: Int) -> Int? { - off + 1 <= f.count ? Int(f[off]) : nil +// +// `limit` is the EXCLUSIVE upper bound for a named inner field (D7): the minimum of where the CRC32 +// trailer starts and how many bytes the frame actually has. It is a required argument, not a +// defaulted one, so adding a field read without deciding its bound does not compile. Callers derive +// it with `payloadLimit(_:trailerStart:)`, which never returns more than `f.count`, so passing it +// here is at least as safe as the old bound and never reads a byte that is not there. + +@inline(__always) private func readU8(_ f: [UInt8], _ off: Int, _ limit: Int) -> Int? { + off >= 0 && off + 1 <= limit ? Int(f[off]) : nil } -@inline(__always) private func readU16(_ f: [UInt8], _ off: Int) -> Int? { - off + 2 <= f.count ? Int(f[off]) | (Int(f[off + 1]) << 8) : nil +@inline(__always) private func readU16(_ f: [UInt8], _ off: Int, _ limit: Int) -> Int? { + off >= 0 && off + 2 <= limit ? Int(f[off]) | (Int(f[off + 1]) << 8) : nil } -@inline(__always) private func readU32(_ f: [UInt8], _ off: Int) -> Int? { - guard off + 4 <= f.count else { return nil } +@inline(__always) private func readU32(_ f: [UInt8], _ off: Int, _ limit: Int) -> Int? { + guard off >= 0, off + 4 <= limit else { return nil } return Int(f[off]) | (Int(f[off + 1]) << 8) | (Int(f[off + 2]) << 16) | (Int(f[off + 3]) << 24) } -@inline(__always) private func readI16(_ f: [UInt8], _ off: Int) -> Int? { - guard off + 2 <= f.count else { return nil } +@inline(__always) private func readI16(_ f: [UInt8], _ off: Int, _ limit: Int) -> Int? { + guard off >= 0, off + 2 <= limit else { return nil } let raw = UInt16(f[off]) | (UInt16(f[off + 1]) << 8) return Int(Int16(bitPattern: raw)) } -@inline(__always) private func readI32(_ f: [UInt8], _ off: Int) -> Int? { - guard off + 4 <= f.count else { return nil } - let raw = UInt32(f[off]) | (UInt32(f[off + 1]) << 8) | (UInt32(f[off + 2]) << 16) | (UInt32(f[off + 3]) << 24) - return Int(Int32(bitPattern: raw)) -} - -@inline(__always) private func readF32(_ f: [UInt8], _ off: Int) -> Double? { - guard off + 4 <= f.count else { return nil } +@inline(__always) private func readF32(_ f: [UInt8], _ off: Int, _ limit: Int) -> Double? { + guard off >= 0, off + 4 <= limit else { return nil } let bits = UInt32(f[off]) | (UInt32(f[off + 1]) << 8) | (UInt32(f[off + 2]) << 16) | (UInt32(f[off + 3]) << 24) return Double(Float(bitPattern: bits)) // float32 -> Double is exact, no rounding } -/// Read a schema dtype at off; returns the integer value or nil if out of range. -private func readDType(_ f: [UInt8], _ off: Int, _ dtype: String) -> Int? { +/// The exclusive upper bound for reading named inner fields out of `frame` (D7). +/// +/// It is the MINIMUM of the CRC32 trailer's start and the frame's real size — not one or the other. +/// The trailer start follows from the DECLARED length, which on a truncated frame points past the +/// last byte we hold, so using it alone would read off the end of the buffer; using only the frame +/// size is what let a frame at the family minimum have its own checksum trailer decoded as a +/// sequence number, a command byte or a metadata type. A field counts as present when its start plus +/// its length does not EXCEED this bound: the smallest real WHOOP 4.0 history frame is 11 bytes with +/// its trailer at 7, and its metadata type occupies precisely the last payload byte. +@inline(__always) +private func payloadLimit(_ frame: [UInt8], trailerStart: Int?) -> Int { + guard let trailerStart = trailerStart else { return frame.count } + return min(max(0, trailerStart), frame.count) +} + +/// Read a schema dtype at off, bounded by `limit`; nil when the field does not fit. +private func readDType(_ f: [UInt8], _ off: Int, _ dtype: String, _ limit: Int) -> Int? { switch dtype { - case "u8": return readU8(f, off) - case "u16": return readU16(f, off) - case "u32": return readU32(f, off) - case "i16": return readI16(f, off) + case "u8": return readU8(f, off, limit) + case "u16": return readU16(f, off, limit) + case "u32": return readU32(f, off, limit) + case "i16": return readI16(f, off, limit) default: return nil } } @@ -128,20 +182,25 @@ public func parseFrame(_ frame: [UInt8], collectFields: Bool = false) -> ParsedF // PuffinCapture, field-asserting tests) do — so on a 1Hz stream or an offload burst this skips a // per-byte `String(format:)` allocation pass whose result was discarded. let rawHex = collectFields ? frame.map { String(format: "%02x", $0) }.joined() : "" - if frame.count < 8 || frame[0] != 0xAA { + let check = verifyFrame(frame) + // Below the family minimum there is no inner record at all — every offset a field would use + // lands in the checksum trailer — so nothing is decoded, not even a packet type. + if frame.count < FrameLimits.whoop4MinimumFrameBytes || frame[0] != 0xAA { return ParsedFrame(ok: false, typeName: "INVALID/FRAGMENT", seq: nil, cmdName: nil, crcOK: nil, lenBytes: frame.count, rawHex: rawHex, - fields: [], parsed: [:]) + fields: [], parsed: [:], rejectReason: check.reason) } let schema = loadSchema() - let check = verifyFrame(frame) let length = check.length let crcOK = check.crc32OK + // D7: named inner fields come only from payload bytes. On WHOOP 4.0 the CRC32 trailer starts at + // the declared length. + let limit = payloadLimit(frame, trailerStart: length) let t = Int(frame[4]) let typeName = schema.typeName(t) - let seq = Int(frame[5]) + let seq = readU8(frame, 5, limit) let fb = FieldBuilder(frame, collectFields: collectFields) // envelope @@ -149,17 +208,17 @@ public func parseFrame(_ frame: [UInt8], collectFields: Bool = false) -> ParsedF fb.add(1, 2, "length", "frame", value: length.map { .int($0) }) fb.add(3, 1, "crc8", "frame", value: .string(String(format: "0x%02X", frame[3]))) fb.add(4, 1, "packet_type", "frame", value: .string(typeName)) - fb.add(5, 1, "seq", "frame", value: .int(Int(frame[5]))) + if let seq = seq { fb.add(5, 1, "seq", "frame", value: .int(seq)) } let spec = schema.packet(forType: t) if spec == nil { - fb.add(6, 1, "cmd", "cmd", value: frame.count > 6 ? .int(Int(frame[6])) : nil) + fb.add(6, 1, "cmd", "cmd", value: readU8(frame, 6, limit).map { .int($0) }) if let length = length { fb.region(7, length, "payload", "unknown") } } else { // static fields from schema for fld in spec!.fields { guard let dtype = fld.dtype else { continue } - guard let val = readDType(frame, fld.off, dtype) else { continue } + guard let val = readDType(frame, fld.off, dtype, limit) else { continue } let value: ParsedValue if let enumKey = fld.`enum` { value = .string(schema.enumName(enumKey, val)) @@ -182,12 +241,14 @@ public func parseFrame(_ frame: [UInt8], collectFields: Bool = false) -> ParsedF note: check.crc32OK == true ? "OK" : "MISMATCH") } - let cmdByte = frame.count > 6 ? Int(frame[6]) : 0 + let cmdByte = readU8(frame, 6, limit) ?? 0 let cmdName = (t == 35 || t == 36) ? schema.enumName("CommandNumber", cmdByte) : nil - return ParsedFrame(ok: true, typeName: typeName, seq: seq, cmdName: cmdName, + // `ok` is the verifier's full verdict, not a constant: the fields above stay decoded so an + // inspector can still read a broken frame, but no consumer may mistake that for integrity. + return ParsedFrame(ok: check.ok, typeName: typeName, seq: seq, cmdName: cmdName, crcOK: crcOK, lenBytes: frame.count, rawHex: rawHex, - fields: fb.fields, parsed: fb.parsed) + fields: fb.fields, parsed: fb.parsed, rejectReason: check.reason) } /// #47: the packet type NAME only — NO CRC verify, NO FieldBuilder — for hot-path pre-filters that just @@ -195,18 +256,25 @@ public func parseFrame(_ frame: [UInt8], collectFields: Bool = false) -> ParsedF /// multi-minute offload of thousands of type-47 records that only ever act on rare EVENT frames, this skips /// the redundant decode for the ~99% that aren't. Mirrors `parseFrame`'s family split EXACTLY — the inner /// type byte is at [4] on WHOOP4 and [8] on 5/MG, with the same lookup each uses (`schema.typeName` / -/// `canonicalTypeName`). Returns nil for a frame too short / wrong SOF — which `parseFrame` would also mark -/// INVALID (never "EVENT") — so a pre-filter guarded on `== "EVENT"` is byte-identical to the full-parse -/// guard. +/// `canonicalTypeName`), and it uses the same family minimum, so a frame this returns nil for is exactly a +/// frame `parseFrame` marks INVALID. +/// +/// What this does NOT tell you is whether the frame is INTACT. It never was a CRC check, but it used to be +/// interchangeable with the full parse for a caller that only asked "is the type EVENT?", because +/// `parseFrame` reported success for anything it could read. `parseFrame` now reports the verifier's +/// verdict, so the two agree on the type NAME and diverge on `ok`: this is a type peek, and a caller that +/// must not act on a corrupt frame has to parse and check the verdict. public func frameTypeName(_ frame: [UInt8], family: DeviceFamily) -> String? { guard frame.first == 0xAA else { return nil } let schema = loadSchema() switch family { case .whoop4: - guard frame.count >= 8 else { return nil } // parseFrame's `count < 8` INVALID guard + // parseFrame's INVALID guard. + guard frame.count >= FrameLimits.whoop4MinimumFrameBytes else { return nil } return schema.typeName(Int(frame[4])) case .whoop5: - guard frame.count >= 12 else { return nil } // parseFrameWhoop5's `count < 12` INVALID guard + // parseFrameWhoop5's INVALID guard. + guard frame.count >= FrameLimits.whoop5MinimumFrameBytes else { return nil } return canonicalTypeName(Int(frame[8]), schema: schema) } } @@ -233,23 +301,27 @@ public func parseFrame(_ frame: [UInt8], family: DeviceFamily, collectFields: Bo private func parseFrameWhoop5(_ frame: [UInt8], collectFields: Bool) -> ParsedFrame { // D#969: gated identically to parseFrame — build the hex only when a consumer reads it. let rawHex = collectFields ? frame.map { String(format: "%02x", $0) }.joined() : "" - // Minimum whoop5 frame: 8 header bytes + 1 inner (type) + 4 CRC32 trailer. - if frame.count < 12 || frame[0] != 0xAA { + let check = verifyFrame(frame, family: .whoop5) + // Minimum whoop5 frame: 8 header bytes + 1 payload byte + 4 CRC32 trailer. Below that, the type + // byte at [8] would be the first byte of the frame's own CRC32 trailer, so nothing is decoded. + if frame.count < FrameLimits.whoop5MinimumFrameBytes || frame[0] != 0xAA { return ParsedFrame(ok: false, typeName: "INVALID/FRAGMENT", seq: nil, cmdName: nil, crcOK: nil, lenBytes: frame.count, rawHex: rawHex, - fields: [], parsed: [:]) + fields: [], parsed: [:], rejectReason: check.reason) } let schema = loadSchema() - let check = verifyFrame(frame, family: .whoop5) let declaredLength = check.length // payload + 4 (CRC32) let crcOK = check.crc32OK // Inner record starts at offset 8: [type][seq][cmd][data…]. let innerStart = 8 + let payloadEnd = declaredLength.map { ($0 + 8) - 4 } // start of CRC32 trailer + // D7: named inner fields come only from payload bytes, bounded by the trailer AND the real size. + let limit = payloadLimit(frame, trailerStart: payloadEnd) let t = Int(frame[innerStart]) let typeName = canonicalTypeName(t, schema: schema) - let seq = frame.count > innerStart + 1 ? Int(frame[innerStart + 1]) : nil + let seq = readU8(frame, innerStart + 1, limit) let fb = FieldBuilder(frame, collectFields: collectFields) // envelope @@ -267,46 +339,47 @@ private func parseFrameWhoop5(_ frame: [UInt8], collectFields: Bool) -> ParsedFr // byte 8 here vs byte 4 on 4.0, so every field sits at its 4.0 offset + `delta`. Verified on real // hardware for REALTIME_DATA (type 40) — HR, R-R and the unix timestamp land exactly at +4 (HR // matched the standard 2A37 profile to ~0.4 bpm). We reuse the 4.0 schema with that shift. - let cmdByte = frame.count > innerStart + 2 ? Int(frame[innerStart + 2]) : 0 + let cmdByte = readU8(frame, innerStart + 2, limit) ?? 0 let delta = innerStart - 4 // = 4 - let payloadEnd = declaredLength.map { ($0 + 8) - 4 } // start of CRC32 trailer let spec = schema.packet(forType: t) if spec == nil { fb.add(innerStart + 2, 1, "cmd", "cmd", - value: frame.count > innerStart + 2 ? .int(cmdByte) : nil) + value: readU8(frame, innerStart + 2, limit).map { .int($0) }) if let payloadEnd = payloadEnd, innerStart + 3 < payloadEnd, payloadEnd <= frame.count { fb.region(innerStart + 3, payloadEnd, "payload", "unknown") } } else { // Static schema fields at the 4.0 offset + delta. for fld in spec!.fields { - guard let dtype = fld.dtype, let val = readDType(frame, fld.off + delta, dtype) else { continue } + guard let dtype = fld.dtype, + let val = readDType(frame, fld.off + delta, dtype, limit) else { continue } let value: ParsedValue = fld.`enum`.map { .string(schema.enumName($0, val)) } ?? .int(val) fb.add(fld.off + delta, fld.len, fld.name, fld.cat, value: value, note: fld.note) } if spec!.post == "realtime_data" { // Verified variable-length extension: REALTIME_DATA R-R intervals (rr_count @13+delta, // intervals @14+delta…), the same shape as 4.0 shifted by +4. - let rrn = readDType(frame, 13 + delta, "u8") ?? 0 + let rrn = readDType(frame, 13 + delta, "u8", limit) ?? 0 var rrs: [Int] = [] for i in 0.. 0 { + if let v = readDType(frame, off, "u16", limit), v > 0 { fb.add(off, 2, "rr[\(i)]", "rr", value: .int(v), note: "ms") rrs.append(v) } } fb.parsed["rr_intervals"] = .intArray(rrs) } else if spec!.post == "historical_data" { - decodeWhoop5Historical(frame, fb: fb, payloadEnd: payloadEnd) + decodeWhoop5Historical(frame, fb: fb, payloadEnd: payloadEnd, limit: limit) } else if spec!.post == "metadata" { - decodeWhoop5Metadata(frame, fb: fb) + decodeWhoop5Metadata(frame, fb: fb, limit: limit) } else if spec!.post == "command_response" { - decodeWhoop5CommandResponse(frame, fb: fb, schema: schema, payloadEnd: payloadEnd) + decodeWhoop5CommandResponse(frame, fb: fb, schema: schema, payloadEnd: payloadEnd, + limit: limit) } else if spec!.post == "event" { - decodeWhoop5Event(frame, fb: fb, schema: schema) + decodeWhoop5Event(frame, fb: fb, schema: schema, limit: limit) } else if spec!.post == "console_logs" { - decodeWhoop5ConsoleLogs(frame, fb: fb, payloadEnd: payloadEnd) + decodeWhoop5ConsoleLogs(frame, fb: fb, payloadEnd: payloadEnd, limit: limit) } else if let payloadEnd = payloadEnd, innerStart + 3 < payloadEnd, payloadEnd <= frame.count { // Other types: static fields decoded above; the remaining variable body is kept raw — // its 4.0 post-hook awaits per-type 5.0 hardware verification before we apply it at +4. @@ -326,9 +399,10 @@ private func parseFrameWhoop5(_ frame: [UInt8], collectFields: Bool) -> ParsedFr let cmdName = (t == 35 || t == 36 || t == PuffinPacketType.puffinCommandResponse) ? schema.enumName("CommandNumber", cmdByte) : nil - return ParsedFrame(ok: true, typeName: typeName, seq: seq, cmdName: cmdName, + // The verifier's verdict, not a constant — see the WHOOP 4.0 parser for the reasoning. + return ParsedFrame(ok: check.ok, typeName: typeName, seq: seq, cmdName: cmdName, crcOK: crcOK, lenBytes: frame.count, rawHex: rawHex, - fields: fb.fields, parsed: fb.parsed) + fields: fb.fields, parsed: fb.parsed, rejectReason: check.reason) } /// The WHOOP 5/MG type-47 `hist_version` values `decodeWhoop5Historical` has a REAL field map for. @@ -367,19 +441,23 @@ public func isUnmappedWhoop5HistoricalRecord(_ frame: [UInt8]) -> Bool { /// PPG / SpO₂ / skin-temp live further in the 124-byte record but lack on-device ground truth, so /// they are left as a raw region rather than guessed (project rule: real captures, never invented /// offsets). -private func decodeWhoop5Historical(_ frame: [UInt8], fb: FieldBuilder, payloadEnd: Int?) { - let version = frame.count > 9 ? Int(frame[9]) : -1 +private func decodeWhoop5Historical(_ frame: [UInt8], fb: FieldBuilder, payloadEnd: Int?, + limit: Int) { + // The layout version is the inner record's seq slot: a named inner field, so it is bounded like + // any other. Out of bounds it stays -1, which routes the record to the unmapped branch below. + let version = readU8(frame, 9, limit) ?? -1 fb.parsed["hist_version"] = .int(version) - fb.add(9, 1, "hist_version", "meta", value: .int(version)) + if version >= 0 { fb.add(9, 1, "hist_version", "meta", value: .int(version)) } // One dispatch, one list: every `case` here must appear in `mappedWhoop5HistoricalVersions` and // vice versa, because that set is what decides whether a record gets archived raw. A `switch` // rather than the old if-chain so the mapped versions are readable in one place. switch version { case 26: - decodeWhoop5HistoricalV26(frame, fb: fb) + decodeWhoop5HistoricalV26(frame, fb: fb, limit: limit) return case 20, 21: - decodeWhoop5HistoricalV2021(frame, fb: fb, version: version, payloadEnd: payloadEnd) + decodeWhoop5HistoricalV2021(frame, fb: fb, version: version, payloadEnd: payloadEnd, + limit: limit) return case 18: break // falls through to the v18 field decode below @@ -391,23 +469,23 @@ private func decodeWhoop5Historical(_ frame: [UInt8], fb: FieldBuilder, payloadE } return } - if let idx = readDType(frame, 11, "u32") { + if let idx = readDType(frame, 11, "u32", limit) { // A per-record counter: +1 every record and independent of unix (it advances across gaps); // observed identical on two straps. @11 is only the low byte — read the full u32 LE. fb.add(11, 4, "record_index", "meta", value: .int(idx), note: "per-record counter") } - if let unix = readDType(frame, 15, "u32") { + if let unix = readDType(frame, 15, "u32", limit) { fb.add(15, 4, "unix", "time", value: .int(unix), note: "real unix seconds") } - if let hr = readDType(frame, 22, "u8") { + if let hr = readDType(frame, 22, "u8", limit) { fb.add(22, 1, "heart_rate", "hr", value: .int(hr), note: "bpm") } - let rrn = readDType(frame, 23, "u8") ?? 0 + let rrn = readDType(frame, 23, "u8", limit) ?? 0 fb.add(23, 1, "rr_count", "rr", value: .int(rrn)) var rrs: [Int] = [] for i in 0.. 0 { + if let v = readDType(frame, off, "u16", limit), v > 0 { fb.add(off, 2, "rr[\(i)]", "rr", value: .int(v), note: "ms") rrs.append(v) } @@ -415,7 +493,7 @@ private func decodeWhoop5Historical(_ frame: [UInt8], fb: FieldBuilder, payloadE fb.parsed["rr_intervals"] = .intArray(rrs) // Bytes adjacent to the HR/R-R fields, read off real frames: @36 is a FLAG byte and @37 a duplicate // heart rate — not the two halves of one fixed-point HR (see below); the others are raw. - if let v = readDType(frame, 33, "u8") { + if let v = readDType(frame, 33, "u8", limit) { fb.add(33, 1, "cardiac_flags", "cardiac", value: .int(v), note: "raw byte near the HR fields") } // @36 was read as the low half of a u16 `hr_fixed_8_8` with bpm = value/256. Over 18,650 real v18 @@ -426,65 +504,65 @@ private func decodeWhoop5Historical(_ frame: [UInt8], fb: FieldBuilder, payloadE // this flag byte over 256, so the residual is a flat +0.504 ± 0.189. Bit 7 reads as a VALIDITY bit: // with it clear (n=748) rr_count == 0 in 70.32% of records vs 19.82% with it set, and the @108/@109 // sentinel fires in 69.65% vs 1.32%. The remaining bits are unpinned, so the byte ships raw. - if let v = readDType(frame, 36, "u8") { + if let v = readDType(frame, 36, "u8", limit) { fb.add(36, 1, "hr_quality_flags", "hr", value: .int(v), note: "flag byte (raw); bit7 = HR/R-R valid, bit4 never set; NOT a fixed-point HR") } // @37 duplicates heart_rate@22 — equal in 99.575% of records (18,523/18,602), differing only by // -6…+2, and it only tracks HR while @36 bit7 is set (99.74% exact vs 94.12% when clear). - if let v = readDType(frame, 37, "u8") { + if let v = readDType(frame, 37, "u8", limit) { fb.add(37, 1, "heart_rate_alt", "hr", value: .int(v), note: "bpm; duplicate of heart_rate@22 (99.6% exact); trust only when hr_quality_flags bit7 is set") } - if let v = readDType(frame, 38, "u16") { + if let v = readDType(frame, 38, "u16", limit) { fb.add(38, 2, "rr_packed", "rr", value: .int(v), note: "raw u16 near the R-R fields; meaning not pinned") } - if let v = readDType(frame, 40, "u8") { + if let v = readDType(frame, 40, "u8", limit) { fb.add(40, 1, "cardiac_status", "cardiac", value: .int(v), note: "raw status-like byte near the HR fields") } for (name, off) in [("gravity_x", 45), ("gravity_y", 49), ("gravity_z", 53)] { - if let d = readF32(frame, off) { + if let d = readF32(frame, off, limit) { fb.add(off, 4, name, "accel", value: .double(d), note: "g") } } // Per-second fields beyond HR/gravity, each gated to a physically-real range and cross-validated // against real v18 frames (worn vs off-wrist), so a wrong offset on an unmapped layout stores // nothing rather than garbage (the data is the arbiter). - if let d = readF32(frame, 41), d.isFinite, (0...8).contains(d) { + if let d = readF32(frame, 41, limit), d.isFinite, (0...8).contains(d) { fb.add(41, 4, "dynamic_acceleration", "accel", value: .double(d), note: "g, gravity-removed magnitude") } - if let raw = readDType(frame, 57, "u16") { + if let raw = readDType(frame, 57, "u16", limit) { // Cumulative motion/step counter — monotonic across a stream (validated downstream), no midnight // reset. Single-frame value is unbounded so it carries no physical gate here. fb.add(57, 2, "step_motion_counter", "activity", value: .int(raw), note: "cumulative motion counter") } - if let cad = readDType(frame, 59, "u8") { + if let cad = readDType(frame, 59, "u8", limit) { // A per-step cadence-like byte between the step counter and @63: never 0, and lower when moving // faster (still > walk > run in the data). Raw — no unit asserted. fb.add(59, 1, "step_cadence", "activity", value: .int(cad), note: "cadence-like byte (raw)") } - if let wear = readDType(frame, 63, "u8"), (0...2).contains(wear) { + if let wear = readDType(frame, 63, "u8", limit), (0...2).contains(wear) { fb.add(63, 1, "motion_wear_quality", "quality", value: .int(wear), note: "0=still/good, 1, 2=poor contact") } // @63 also reads as a small validated ACTIVITY-CLASS enum (community finding, #316): 0=still, 1=walk, // 2=run, 0xFF=invalid. A lightweight, no-cloud per-record activity readout that rides alongside the // step counter. Only the four known codes are surfaced — anything else (incl. 0xFF) stores nothing so // an unmapped firmware can't inject garbage. - if let cls = readDType(frame, 63, "u8"), cls == 0 || cls == 1 || cls == 2 { + if let cls = readDType(frame, 63, "u8", limit), cls == 0 || cls == 1 || cls == 2 { fb.add(63, 1, "activity_class", "activity", value: .int(cls), note: "0=still, 1=walk, 2=run (0xFF=invalid)") } // Two auxiliary thermal channels just before skin_temp. Each is a signed i16 whose value/10 reads as // °C, tracks skin_temp@73 closely (corr ~0.92 and ~0.97 across the captured corpus) and follows the // same diurnal curve. Gated to a plausible thermal range so a wrong offset stores nothing. - if let v = readI16(frame, 69), (0...60).contains(Double(v) / 10.0) { + if let v = readI16(frame, 69, limit), (0...60).contains(Double(v) / 10.0) { fb.add(69, 2, "temp_aux_1_raw", "temp", value: .int(v), note: "secondary temperature channel; °C = value/10; tracks skin_temp (corr ~0.92) with the same diurnal curve") } - if let v = readI16(frame, 71), (0...60).contains(Double(v) / 10.0) { + if let v = readI16(frame, 71, limit), (0...60).contains(Double(v) / 10.0) { fb.add(71, 2, "temp_aux_2_raw", "temp", value: .int(v), note: "secondary temperature channel; °C = value/10; tracks skin_temp (corr ~0.97) with the same diurnal curve") } - if let raw = readDType(frame, 73, "u16") { + if let raw = readDType(frame, 73, "u16", limit) { // Skin temperature from a digital skin-temperature sensor. Emitted as the RAW u16 register // (`skin_temp_raw`, consumed by the decode-features store) to stay scale-agnostic; °C = raw/100 // is the divisor that yields physiological worn skin temps (median ~34 °C across two straps; @@ -498,20 +576,20 @@ private func decodeWhoop5Historical(_ frame: [UInt8], fb: FieldBuilder, payloadE note: "raw register; °C = raw/100 (≈30.6 worn / ~22.5 ambient; on-wrist warming curve)") } } - if let raw = readDType(frame, 75, "u16") { + if let raw = readDType(frame, 75, "u16", limit) { // A 16-bit status word. NOT a deep-sleep marker: across ~258k records its low nibble is 0 and it // occurs as often awake as asleep (the community "80 = deep" reading is a misread). fb.add(75, 2, "status_word", "status", value: .int(raw), note: "packed status word; not deep-sleep") } - if let v = readDType(frame, 77, "u16") { + if let v = readDType(frame, 77, "u16", limit) { fb.add(77, 2, "status_word_1", "status", value: .int(v), note: "raw; near-static sibling of status_word@75 (low nibble = 1)") } - if let v = readDType(frame, 79, "u16") { + if let v = readDType(frame, 79, "u16", limit) { fb.add(79, 2, "status_word_2", "status", value: .int(v), note: "raw; sibling of @75 (low nibble = 2)") } - if let sb = readDType(frame, 81, "u8") { + if let sb = readDType(frame, 81, "u8", limit) { // High nibble (bits 4-5) tracks a scored night: 0 wake / 1 still / 2 asleep / 3 up; low nibble = // sub-flags. Deep/REM/light are computed off-band, not present here. let state = (sb >> 4) & 3 @@ -529,7 +607,7 @@ private func decodeWhoop5Historical(_ frame: [UInt8], fb: FieldBuilder, payloadE fb.add(81, 1, "sleep_state_byte", "sleep", value: .int(sb), note: "the RAW @81 flag byte, all 8 bits (b0-1 onwrist, b2-3 wake_quality, b4-5 sleep_state, b6-7 reserved)") } - if let v = readDType(frame, 82, "u8") { + if let v = readDType(frame, 82, "u8", limit) { fb.add(82, 1, "aux_byte_82", "status", value: .int(v), note: "raw; nonzero only while sleep_state = asleep; tri-mode, see spo2_candidate_82") // #103 SpO2 candidate: a decompile-sourced decode (gen5.rs `spo2_pct`, reimplemented here as a @@ -569,7 +647,7 @@ private func decodeWhoop5Historical(_ frame: [UInt8], fb: FieldBuilder, payloadE // unremarkably while worn (@106 in 10 records, @107 in 103). Magnitudes are device-specific (102–255 / // 119–247 on the R22 strap vs 20–66 / 34–81 on another), so no scale is asserted. for (name, off) in [("optical_baseline_a", 106), ("optical_baseline_b", 107)] { - if let v = readDType(frame, off, "u8") { + if let v = readDType(frame, off, "u8", limit) { fb.add(off, 1, name, "optical", value: .int(v), note: "u8 optical/ADC baseline channel; wanders overnight, 0 = off-wrist; raw, not pinned") } @@ -586,15 +664,15 @@ private func decodeWhoop5Historical(_ frame: [UInt8], fb: FieldBuilder, payloadE // and predicts the band's own beat-detection failure (rr_count == 0) at 79.44% vs 19.40% — a 4.09× // lift that SURVIVES holding motion constant (4.11× within dyn_acc < 0.009 g), where shuffled and // circular-shift nulls all sit at ~1.0×. It is the same quality condition @36 bit7 reports. - if let a = readDType(frame, 108, "u8") { + if let a = readDType(frame, 108, "u8", limit) { fb.add(108, 1, "optical_amp_a", "optical", value: .int(a), note: "paired optical channel A (≈ optical_amp_b@109); tracks motion, not HR; 128 = record-level signal-quality sentinel; raw") } - if let b = readDType(frame, 109, "u8") { + if let b = readDType(frame, 109, "u8", limit) { fb.add(109, 1, "optical_amp_b", "optical", value: .int(b), note: "paired optical channel B (see optical_amp_a@108); 128 = record-level sentinel, always together with @108; raw") } - if let d = readF32(frame, 113), d.isFinite { + if let d = readF32(frame, 113, limit), d.isFinite { // A float32 at @113 (observed range ~ -5.3…0, 0 = unset); purpose unknown, carried raw. fb.add(113, 4, "unknown_f32_113", "aux", value: .double(d), note: "float32, purpose unknown") } @@ -624,28 +702,28 @@ private func decodeWhoop5Historical(_ frame: [UInt8], fb: FieldBuilder, payloadE /// The samples are raw AC-coupled ADC counts — PPG has no absolute unit — so they are exposed verbatim /// as `ppg_waveform` with NO invented scale. The bytes before [27] (header + a block index) and the /// footer after [75] are not mapped; SpO₂/skin-temp have no internal proxy and are left untouched. -private func decodeWhoop5HistoricalV26(_ frame: [UInt8], fb: FieldBuilder) { +private func decodeWhoop5HistoricalV26(_ frame: [UInt8], fb: FieldBuilder, limit: Int) { // @21 is a small per-burst counter (`burst_index`), NOT the optical channel (PR#553). An earlier read // labelled it `ppg_channel` and gated it 1…26 because our two original fixtures happened to read 1 and // 2 — but a later capture read 65, far outside any 26-channel sweep, so @21 is not a stable channel id. // Re-surfaced as the neutral `burst_index` (gated bi > 0, the observed always-set sentinel) with NO // channel/LED semantics claimed; the physical optical-channel mapping is unproven and left unasserted. - if let bi = readDType(frame, 21, "u8"), bi > 0 { + if let bi = readDType(frame, 21, "u8", limit), bi > 0 { fb.add(21, 1, "burst_index", "ppg", value: .int(bi), note: "per-burst counter (raw); NOT a channel id") } // record_index@11 (PR#563): the same monotonic lifetime per-record counter the v18/v20/v21 records // carry at @11 — +1 per record, independent of unix (advances across gaps). The only @11+ v26 field // proven by behaviour; everything else below stays raw/neutral. - if let idx = readDType(frame, 11, "u32") { + if let idx = readDType(frame, 11, "u32", limit) { fb.add(11, 4, "record_index", "meta", value: .int(idx), note: "monotonic lifetime record index") } - if let unix = readDType(frame, 15, "u32") { + if let unix = readDType(frame, 15, "u32", limit) { fb.add(15, 4, "unix", "time", value: .int(unix), note: "real unix seconds") } var samples: [Int] = [] for off in stride(from: 27, to: 75, by: 2) { - guard let v = readI16(frame, off) else { break } + guard let v = readI16(frame, off, limit) else { break } samples.append(v) } if !samples.isEmpty { @@ -659,7 +737,7 @@ private func decodeWhoop5HistoricalV26(_ frame: [UInt8], fb: FieldBuilder) { // waveform block; @75/@79/@81/@82 trail it. (`@27…@75` is the proven waveform handled above.) for (name, off) in [("raw_u8_19", 19), ("raw_u8_23", 23), ("raw_u8_25", 25), ("raw_u8_75", 75), ("raw_u8_79", 79), ("raw_u8_81", 81), ("raw_u8_82", 82)] { - if let v = readDType(frame, off, "u8") { + if let v = readDType(frame, off, "u8", limit) { fb.add(off, 1, name, "raw", value: .int(v), note: "raw byte @\(off); meaning not pinned") } } @@ -686,14 +764,15 @@ private func decodeWhoop5HistoricalV26(_ frame: [UInt8], fb: FieldBuilder) { /// but its measurement wavelengths and channel geometry remain OPEN, so those channels stay neutrally /// named. This layer exposes raw i16 (v21) or sign-extended i32 (v20) arrays; `Whoop5RawImu.decode` /// applies the v21 physical scales. -private func decodeWhoop5HistoricalV2021(_ frame: [UInt8], fb: FieldBuilder, version: Int, payloadEnd: Int?) { - if frame.count > 10 { - fb.add(10, 1, "layout_marker", "meta", value: .int(Int(frame[10]))) +private func decodeWhoop5HistoricalV2021(_ frame: [UInt8], fb: FieldBuilder, version: Int, + payloadEnd: Int?, limit: Int) { + if let marker = readU8(frame, 10, limit) { + fb.add(10, 1, "layout_marker", "meta", value: .int(marker)) } - if let idx = readU32(frame, 11) { + if let idx = readU32(frame, 11, limit) { fb.add(11, 4, "record_index", "meta", value: .int(idx), note: "monotonic lifetime record index") } - if let unix = readU32(frame, 15) { + if let unix = readU32(frame, 15, limit) { fb.add(15, 4, "unix", "time", value: .int(unix), note: "real unix seconds") } if version == 21 { @@ -711,7 +790,7 @@ private func decodeWhoop5HistoricalV2021(_ frame: [UInt8], fb: FieldBuilder, ver for (name, start) in channels { var samples: [Int] = [] for i in 0..<100 { - guard let v = readI16(frame, start + i * 2) else { break } + guard let v = readI16(frame, start + i * 2, limit) else { break } samples.append(v) } if samples.count == 100 { @@ -767,10 +846,10 @@ private func decodeWhoop5HistoricalV2021(_ frame: [UInt8], fb: FieldBuilder, ver /// drive the `HISTORICAL_DATA_RESULT` ack. Offsets are the 4.0 metadata post-hook positions + 4, /// verified on real WHOOP 5 HISTORY_END frames (trim decodes consistently across a whole capture). /// `end_data` to echo back in the ack is `frame[21..29]` (trim u32 + next u32). -private func decodeWhoop5Metadata(_ frame: [UInt8], fb: FieldBuilder) { - if let unix = readDType(frame, 11, "u32") { fb.add(11, 4, "unix", "time", value: .int(unix)) } - if let ss = readDType(frame, 15, "u16") { fb.add(15, 2, "subsec", "time", value: .int(ss)) } - if let trim = readDType(frame, 21, "u32") { +private func decodeWhoop5Metadata(_ frame: [UInt8], fb: FieldBuilder, limit: Int) { + if let unix = readDType(frame, 11, "u32", limit) { fb.add(11, 4, "unix", "time", value: .int(unix)) } + if let ss = readDType(frame, 15, "u16", limit) { fb.add(15, 2, "subsec", "time", value: .int(ss)) } + if let trim = readDType(frame, 21, "u32", limit) { fb.add(21, 4, "trim_cursor", "meta", value: .int(trim), note: "ack with this to advance") } } @@ -790,9 +869,10 @@ public func whoop5HistoricalAckFrame(endData: [UInt8], seq: UInt8) -> [UInt8] { /// mapped from a real WHOOP 5 capture (firmware 50.38.1.0), not ported on faith. Commands that return /// a short stub on this firmware (REPORT_VERSION_INFO / GET_EXTENDED_BATTERY_INFO) or aren't served /// (GET_CLOCK — unneeded, since realtime + historical carry real unix) are intentionally left undecoded. -private func decodeWhoop5CommandResponse(_ frame: [UInt8], fb: FieldBuilder, schema: Schema, payloadEnd: Int?) { +private func decodeWhoop5CommandResponse(_ frame: [UInt8], fb: FieldBuilder, schema: Schema, + payloadEnd: Int?, limit: Int) { guard let payloadEnd = payloadEnd, 11 < payloadEnd, payloadEnd <= frame.count else { return } - let respCmd = Int(frame[10]) + guard let respCmd = readU8(frame, 10, limit) else { return } let name = schema.enumName("CommandNumber", respCmd) // e.g. "GET_BATTERY_LEVEL(26)" let pay = Array(frame[11.. Int? { off + 1 <= f.count ? Int(f[off]) : nil } -private func u16(_ f: [UInt8], _ off: Int) -> Int? { - off + 2 <= f.count ? Int(f[off]) | (Int(f[off + 1]) << 8) : nil +/// The exclusive upper bound for reading a named inner field out of `frame` (D7), computed exactly as +/// the interpreter's own `payloadLimit`: the MINIMUM of where the CRC32 trailer starts and how many +/// bytes the frame actually has. +/// +/// The post-hooks run on the parse path and read the schema-driven fields, so they need the same bound +/// the interpreter applies to the fields it reads itself — clamping only against the frame size lets a +/// frame near the family minimum have its own checksum trailer decoded as a metadata value. Neither +/// half may be dropped: the trailer start follows from the DECLARED length, which on a truncated frame +/// points past the last byte we hold, and reading to it alone would run off the end of the buffer. +/// A field counts as present when its start plus its length does not EXCEED this bound. +/// +/// On WHOOP 4.0 — the only family whose parse dispatches through this registry — the trailer starts at +/// the declared length, which is exactly the `length` argument every hook is handed. +private func payloadLimit(_ frame: [UInt8], trailerStart: Int?) -> Int { + guard let trailerStart = trailerStart else { return frame.count } + return min(max(0, trailerStart), frame.count) +} + +// Local LE readers mirroring interpreter._read (nil when out of range). `limit` is the D7 payload bound +// from `payloadLimit`; it is a required argument, never defaulted, so a field read added later cannot +// silently skip the decision about where its bytes may come from. +private func u8(_ f: [UInt8], _ off: Int, _ limit: Int) -> Int? { + off >= 0 && off + 1 <= limit ? Int(f[off]) : nil +} +private func u16(_ f: [UInt8], _ off: Int, _ limit: Int) -> Int? { + off >= 0 && off + 2 <= limit ? Int(f[off]) | (Int(f[off + 1]) << 8) : nil } -private func u32(_ f: [UInt8], _ off: Int) -> UInt32? { - guard off + 4 <= f.count else { return nil } +private func u32(_ f: [UInt8], _ off: Int, _ limit: Int) -> UInt32? { + guard off >= 0, off + 4 <= limit else { return nil } return UInt32(f[off]) | (UInt32(f[off + 1]) << 8) | (UInt32(f[off + 2]) << 16) | (UInt32(f[off + 3]) << 24) } /// signed 24-bit little-endian (mirrors interpreter._read "s24"); nil when out of range. -private func s24(_ f: [UInt8], _ off: Int) -> Int? { - guard off + 3 <= f.count else { return nil } +private func s24(_ f: [UInt8], _ off: Int, _ limit: Int) -> Int? { + guard off >= 0, off + 3 <= limit else { return nil } let v = Int(f[off]) | (Int(f[off + 1]) << 8) | (Int(f[off + 2]) << 16) return (v & 0x800000) != 0 ? v - 0x1000000 : v } /// IEEE-754 float32 LE -> Double (exact, NO rounding). nil when out of range. -private func f32(_ f: [UInt8], _ off: Int) -> Double? { - guard let bits = u32(f, off) else { return nil } +private func f32(_ f: [UInt8], _ off: Int, _ limit: Int) -> Double? { + guard let bits = u32(f, off, limit) else { return nil } return Double(Float(bitPattern: bits)) } /// Read an unsigned integer dtype (u8/u16/u32) as Int; nil when out of range. -private func readHistInt(_ f: [UInt8], _ off: Int, _ dtype: String) -> Int? { +private func readHistInt(_ f: [UInt8], _ off: Int, _ dtype: String, _ limit: Int) -> Int? { switch dtype { - case "u8": return u8(f, off) - case "u16": return u16(f, off) - case "u32": return u32(f, off).map { Int($0) } + case "u8": return u8(f, off, limit) + case "u16": return u16(f, off, limit) + case "u32": return u32(f, off, limit).map { Int($0) } default: return nil } } -/// Read `count` signed i16 LE starting at off, clamping count to the available bytes +/// Read `count` signed i16 LE starting at off, clamping count to the readable payload bytes /// (mirrors interpreter._i16_block). -private func i16Block(_ frame: [UInt8], _ off: Int, _ count: Int) -> [Int] { +private func i16Block(_ frame: [UInt8], _ off: Int, _ count: Int, _ limit: Int) -> [Int] { + guard off >= 0 else { return [] } var n = count - if off + n * 2 > frame.count { - n = max(0, (frame.count - off) / 2) + if off + n * 2 > limit { + n = max(0, (limit - off) / 2) } guard n > 0 else { return [] } var out: [Int] = [] @@ -104,13 +127,17 @@ private let utcRangeFormatter: DateFormatter = { }() func registerPostHooks() { - postHooks["realtime_data"] = { fb, frame, _, _ in - let rrn = u8(frame, 13) ?? 0 + postHooks["realtime_data"] = { fb, frame, length, _ in + let limit = payloadLimit(frame, trailerStart: length) + // D7: the R-R count is a named inner field, so it comes from payload bytes only. On a frame + // whose record is cut short, the byte at 13 is part of the CRC32 trailer — reading it as a + // count made the trailer dictate how many "intervals" to decode out of the trailer itself. + let rrn = u8(frame, 13, limit) ?? 0 var rrs: [Int] = [] for i in 0.. 0 { + if let v = u16(frame, 14 + i * 2, limit), v > 0 { fb.add(14 + i * 2, 2, "rr[\(i)]", "rr", value: .int(v), note: "ms") rrs.append(v) } @@ -119,7 +146,8 @@ func registerPostHooks() { } postHooks["event"] = { fb, frame, length, schema in - let evVal = frame.count > 6 ? Int(frame[6]) : nil + let limit = payloadLimit(frame, trailerStart: length) + let evVal = u8(frame, 6, limit) let evName = evVal.flatMap { schema.enums["EventNumber"]?[String($0)] } guard let length = length else { return } if evName == "BATTERY_LEVEL" { @@ -128,18 +156,18 @@ func registerPostHooks() { // u32 event_timestamp@8). Emitted ~every 8 min → a DENSE battery series. // soc% = u16@17/10 · mV = u16@21 · charging = u8@26 bit0 fb.region(7, length, "BATTERY_LEVEL payload", "battery", note: "soc@17(/10) mv@21 charge@26") - if let raw = u16(frame, 17), raw <= 1100 { + if let raw = u16(frame, 17, limit), raw <= 1100 { fb.parsed["battery_pct"] = .double(Double(raw) / 10) } - if let mv = u16(frame, 21), (3000...4300).contains(mv) { + if let mv = u16(frame, 21, limit), (3000...4300).contains(mv) { fb.parsed["battery_mV"] = .int(mv) } - if let ch = u8(frame, 26), ch <= 1 { + if let ch = u8(frame, 26, limit), ch <= 1 { fb.parsed["battery_charging"] = .int(ch & 1) } } else if evName == "EXTENDED_BATTERY_INFORMATION" { // Not decoded by the WHOOP app; keep the heuristic mV scan only. - let payEnd = min(length, frame.count) + let payEnd = limit guard 7 < payEnd else { return } let pay = Array(frame[7..= 2 { fb.add(8, 1, "result", "cmd", value: .string(schema.enumName("CommandResult", Int(pay[1])))) } - let cmd = frame.count > 6 ? Int(frame[6]) : nil + let cmd = u8(frame, 6, payEnd) let name = cmd.flatMap { schema.enums["CommandNumber"]?[String($0)] } switch name { case "GET_BATTERY_LEVEL" where pay.count >= 4: @@ -229,6 +259,7 @@ func registerPostHooks() { postHooks["raw_data"] = { fb, frame, length, schema in guard let length = length else { return } + let limit = payloadLimit(frame, trailerStart: length) let spec = schema.packet(forType: Int(frame[4])) let dataLen = length - 7 guard let variant = spec?.variants[String(dataLen)] else { @@ -241,20 +272,20 @@ func registerPostHooks() { let rrFirstOff = variant.rrFirstOff, let samples = variant.samples, let tailFrom = variant.tailFrom else { return } - let hr = u8(frame, hrOff) - let rrn = u8(frame, rrCountOff) ?? 0 + let hr = u8(frame, hrOff, limit) + let rrn = u8(frame, rrCountOff, limit) ?? 0 fb.add(hrOff, 1, "heart_rate", "hr", value: hr.map { .int($0) }, note: "bpm") fb.add(rrCountOff, 1, "rr_count", "rr", value: .int(rrn)) var rrVals: [Int] = [] for i in 0..= 79 { - if let unix = u32(frame, 11) { + if let unix = u32(frame, 11, limit) { fb.add(11, 4, "unix", "time", value: .int(Int(unix)), note: "real unix seconds") fb.parsed["unix"] = .int(Int(unix)) } func grav(_ off: Int) -> Double? { - guard let u = u16(frame, off) else { return nil } + guard let u = u16(frame, off, limit) else { return nil } return Double(u >= 32768 ? u - 65536 : u) / 16384.0 // i16 LE, ±2 g full-scale } if let gx = grav(73), let gy = grav(75), let gz = grav(77) { @@ -356,14 +391,14 @@ func registerPostHooks() { let value: ParsedValue switch dtype { case "u8", "u16", "u32": - guard let v = readHistInt(frame, fld.off, dtype) else { continue } + guard let v = readHistInt(frame, fld.off, dtype, limit) else { continue } if let enumKey = fld.`enum` { value = .string(schema.enumName(enumKey, v)) } else { value = .int(v) } case "f32": - guard let d = f32(frame, fld.off) else { continue } + guard let d = f32(frame, fld.off, limit) else { continue } value = .double(d) // NO rounding — float32->Double is exact. default: continue @@ -375,7 +410,7 @@ func registerPostHooks() { let rrn = fb.parsed["rr_count"]?.intValue ?? 0 for i in 0..= 14 { @@ -426,7 +464,9 @@ func registerPostHooks() { guard let length = length else { return } var txt = "" let lo = 11 - let hi = length - 1 + // The log text ends one byte before the CRC32 trailer, and never past the bytes we hold: the + // same D7 minimum, so a truncated frame yields a shorter string instead of no string at all. + let hi = min(length - 1, payloadLimit(frame, trailerStart: length)) if lo < hi && hi <= frame.count { txt = String(decoding: Array(frame[lo.. [GroupInventory] { let groups = Dictionary(grouping: records, by: { groupKey($0.frame) }) return groups.map { key, recs in @@ -244,7 +256,8 @@ public enum ReTools { let lens = recs.map { $0.bytes.count } return GroupInventory( key: key, count: recs.count, - okCount: recs.filter { $0.frame.ok }.count, + intactCount: recs.filter { $0.frame.ok }.count, + parsableCount: recs.filter { $0.frame.isParsable }.count, crcOkCount: recs.filter { $0.frame.crcOK == true }.count, firstTsMs: ts.min(), lastTsMs: ts.max(), minLen: lens.min() ?? 0, maxLen: lens.max() ?? 0) diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/ScanAdvertisementSummary.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/ScanAdvertisementSummary.swift new file mode 100644 index 0000000000..a17a84965a --- /dev/null +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/ScanAdvertisementSummary.swift @@ -0,0 +1,82 @@ +import Foundation + +/// A redacted description of what a strap ADVERTISED, for the #1635 pairing-mode question. +/// +/// The one thing no strap log can currently answer is the question the field report put back to the +/// thread: *was the strap in pairing mode during any of those refusals?* A strap that accepts pairing +/// almost certainly advertises differently from one that refuses, but the scan path reads only the +/// device name and discards the rest, so the evidence is thrown away at the moment it exists. +/// +/// It is also the ONLY diagnostic on this path that still works unbonded. The event census and the +/// battery-pack read both ride characteristics that need an encrypted link, so on the strap we are +/// actually trying to debug they are silent by construction. An advertisement arrives before any of it. +/// +/// STRUCTURE, NOT PAYLOAD. The local name can carry a person's name ("'s Whoop" is what WHOOP +/// sets by default), service data can carry a serial, and manufacturer data is opaque. So this reports +/// what is PRESENT and how big it is and never a byte of any of it — enough to tell two advertising +/// modes apart, which is the whole question, and carrying nothing identifying. +/// +/// Kotlin twin: `ScanAdvertisementSummary`. +public enum ScanAdvertisementSummary { + + /// - Parameters: + /// - localNameLength: the local name's SIZE IN UTF-8 BYTES (see `localNameLength(_:)`), which can + /// differ between advertising modes and, unlike the name itself, identifies nobody. + public static func line(flags: Int?, + serviceUuids: [String], + serviceDataLengths: [String: Int], + manufacturerDataLengths: [Int: Int], + txPower: Int?, + localNameLength: Int?, + connectable: Bool) -> String { + var parts: [String] = [] + parts.append("flags=" + (flags.map { String(format: "0x%02x", $0) } ?? "none")) + parts.append("connectable=\(connectable)") + let svc = serviceUuids.map(canonicalUuid).sorted() + parts.append("svc=" + (svc.isEmpty ? "none" : svc.joined(separator: ","))) + // Normalise BEFORE sorting: the canonical form reorders keys that the short form would not. + let svcData = serviceDataLengths.map { (canonicalUuid($0.key), $0.value) }.sorted { $0.0 < $1.0 } + parts.append("svcData=" + (svcData.isEmpty ? "none" + : svcData.map { "\($0.0):\($0.1)B" }.joined(separator: ","))) + parts.append("mfg=" + (manufacturerDataLengths.isEmpty ? "none" + : manufacturerDataLengths.sorted { $0.key < $1.key } + .map { String(format: "0x%04x:%dB", $0.key, $0.value) }.joined(separator: ","))) + parts.append("tx=" + (txPower.map(String.init) ?? "none")) + parts.append("nameLen=" + (localNameLength.map(String.init) ?? "none")) + return "[adv] " + parts.joined(separator: " ") + } + + /// Expand an assigned short Bluetooth UUID to its canonical 128-bit form; pass anything else through. + /// + /// CoreBluetooth renders an assigned 16-bit UUID as "180d" and a 32-bit one as "0000180d", where + /// Android's `UUID.toString()` always expands to the full base UUID. Unnormalised, the same strap + /// advertising Heart Rate Service logs a different string on each platform, so an iOS capture cannot + /// be compared against an Android one — which is exactly what this line exists to allow. Normalising + /// inside `line` rather than at the call site means neither platform's scan path can forget to do it. + /// + /// A no-op on Android, whose input is already 128-bit; it lives in both twins so the two formatters + /// stay behaviourally identical rather than agreeing only by accident of their inputs. + static func canonicalUuid(_ s: String) -> String { + switch s.count { + case 4: return "0000\(s)-0000-1000-8000-00805f9b34fb" + case 8: return "\(s)-0000-1000-8000-00805f9b34fb" + default: return s + } + } + + /// The local name's size in UTF-8 BYTES — what the advertisement actually carries. + /// + /// NOT `String.count`. Swift counts grapheme clusters and Java's `String.length` counts UTF-16 code + /// units, so a strap named "Whoop 🎉" reported 7 on iOS and 8 on Android — the same divergence, and + /// for the same reason, as the short-UUID spelling this file already canonicalises. Names carry + /// emoji and accents routinely (WHOOP seeds the name from the account holder), so this was not a + /// theoretical case. + /// + /// Bytes rather than either platform's string model because the AD local-name field IS a UTF-8 byte + /// run: it is the physically meaningful number, and it is identical on both platforms by + /// construction instead of by one imitating the other. + public static func localNameLength(_ name: String?) -> Int? { + name.map { $0.utf8.count } + } + +} diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/Streams.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/Streams.swift index 4a8a30701d..aacfbeb8bd 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/Streams.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/Streams.swift @@ -10,6 +10,30 @@ public struct HRSample: Equatable, Codable { public init(ts: Int, bpm: Int) { self.ts = ts; self.bpm = bpm } } +/// Sensor-contact state carried by the standard BLE Heart Rate Measurement flags. +/// +/// This is deliberately a tri-state rather than a Bool: a peripheral that does not support contact +/// detection has not said that contact is absent. `unsupported` is also the only valid result whenever +/// flag bit 2 is clear, irrespective of the value in detected bit 1. +public enum StandardHRContact: String, Equatable, Codable, Sendable { + case unsupported + case supportedNotDetected = "supported_not_detected" + case supportedDetected = "supported_detected" + + /// Decode the BLE Heart Rate Measurement flags: bit 2 = supported, bit 1 = detected. + /// `unsupported` wins whenever bit 2 is clear, irrespective of detected bit 1. + /// Twin of Kotlin `StandardHrContact.fromMeasurementFlags`. + public static func fromMeasurementFlags(_ flags: UInt8) -> StandardHRContact { + if flags & 0x04 == 0 { + return .unsupported + } else if flags & 0x02 == 0 { + return .supportedNotDetected + } else { + return .supportedDetected + } + } +} + /// WHICH sensor channel produced an R-R interval (#1071). /// /// A WHOOP strap has ONE beat source, so its rows carry no channel (nil) and nothing here changes for @@ -57,8 +81,30 @@ public struct RRInterval: Equatable, Codable { /// that second, so the over-count is accumulation across offloads. WHOOP's wire format has no /// channel field, so `srcChannel` can never answer this for a strap — `ord` is what is left. public let ord: Int? - public init(ts: Int, rrMs: Int, srcChannel: RRSourceChannel? = nil, ord: Int? = nil) { - self.ts = ts; self.rrMs = rrMs; self.srcChannel = srcChannel; self.ord = ord + /// Storage identity for equal beats in the same second. Defaults to zero so wire decoders and + /// callers that predate the widened database key remain source- and behaviour-compatible. + public let seq: Int + public init(ts: Int, rrMs: Int, srcChannel: RRSourceChannel? = nil, ord: Int? = nil, seq: Int = 0) { + self.ts = ts; self.rrMs = rrMs; self.srcChannel = srcChannel; self.ord = ord; self.seq = seq + } + + private enum CodingKeys: String, CodingKey { case ts, rrMs, srcChannel, ord, seq } + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + ts = try c.decode(Int.self, forKey: .ts) + rrMs = try c.decode(Int.self, forKey: .rrMs) + srcChannel = try c.decodeIfPresent(RRSourceChannel.self, forKey: .srcChannel) + ord = try c.decodeIfPresent(Int.self, forKey: .ord) + seq = try c.decodeIfPresent(Int.self, forKey: .seq) ?? 0 + } + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(ts, forKey: .ts) + try c.encode(rrMs, forKey: .rrMs) + try c.encodeIfPresent(srcChannel, forKey: .srcChannel) + try c.encodeIfPresent(ord, forKey: .ord) + // Preserve the legacy encoded shape for the overwhelmingly common seq=0 row. + if seq != 0 { try c.encode(seq, forKey: .seq) } } } @@ -754,11 +800,16 @@ private func toWall(_ deviceTs: Int?, _ deviceClockRef: Int, _ wallClockRef: Int /// /// HR/R-R are taken ONLY from REALTIME_DATA (type 40). REALTIME_RAW_DATA (type 43) also /// carries an HR byte but streams alongside type-40 during raw collection, so routing both -/// would double-count HR for the same instants. CRC-failed and non-ok frames are skipped. +/// would double-count HR for the same instants. Frames whose integrity verdict is negative are +/// skipped — no row is derived from a frame that is not intact. public func extractStreams(_ parsed: [ParsedFrame], deviceClockRef: Int, wallClockRef: Int) -> Streams { var out = Streams() for r in parsed { + // `ok` is now the FULL verdict — header checksum, payload CRC32 and structural length — so it + // alone rejects everything the two-part check used to. The `crcOK` half is kept for the same + // reason as in `extractHistoricalStreams`: a parse result decoded from a capture written before + // the verdict widened carries the old constant `ok: true` beside a false `crcOK`. if !r.ok || r.crcOK == false { continue } let p = r.parsed switch r.typeName { diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop4HelloSerial.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop4HelloSerial.swift new file mode 100644 index 0000000000..50d38d5d7e --- /dev/null +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop4HelloSerial.swift @@ -0,0 +1,81 @@ +import Foundation + +/// The WHOOP 4.0 strap serial, read out of the `GET_HELLO_HARVARD` (cmd 35) response (#1193). +/// +/// A 4.0 does not expose the DIS Serial Number String (`0x2A25`) that gives the 5/MG its stable id, so +/// until now the only 4.0 identity available was the CoreBluetooth peripheral UUID — which iOS re-mints +/// whenever the strap is re-paired or re-keyed. That is the whole of #1193: one physical strap forks +/// into a second registry row and its history is orphaned beside it. +/// +/// The capture aid added for that hunt found the answer: in a 131-byte 4.0 cmd-35 response the serial is +/// the 9-character alphanumeric run at offset 14. +/// +/// ## What this deliberately does NOT read +/// +/// The same response carries a 54-character alphanumeric run at offset 24 which is the strap's DEVICE +/// KEY. This decoder reads a fixed 9-byte window and cannot reach it: there is no scanning, no +/// "longest alnum run", nothing that could drift onto the key as payloads vary. A key must never become +/// an id, reach a log, or leave the device, and the cheapest way to guarantee that is to never read it. +/// +/// ## Why the strict shape check +/// +/// The offset comes from ONE capture. If a future firmware moves the field, a fixed offset lands on +/// whatever is there instead — so a window that is not entirely `[0-9A-Za-z]` is refused rather than +/// returned. `WhoopSerialIdentity.adoptedId` then applies its own validation on top. Refusing costs a +/// strap the stable id it would have gained; returning something wrong migrates its whole history onto +/// a junk key, which is the failure this exists to prevent. +public enum Whoop4HelloSerial { + + /// Offset of the serial run inside the cmd-35 response payload. + public static let serialOffset = 14 + /// Length of the serial run. The 54-char device key begins at offset 24, one byte past this window. + public static let serialLength = 9 + + /// The strap serial, or nil when the payload is too short or the window is not a clean alnum run. + public static func decode(payload: [UInt8]) -> String? { + guard payload.count >= serialOffset + serialLength else { return nil } + var out = "" + out.reserveCapacity(serialLength) + for i in serialOffset..<(serialOffset + serialLength) { + let b = payload[i] + let isDigit = b >= 0x30 && b <= 0x39 + let isUpper = b >= 0x41 && b <= 0x5A + let isLower = b >= 0x61 && b <= 0x7A + guard isDigit || isUpper || isLower else { return nil } + out.append(Character(UnicodeScalar(b))) + } + return out + } +} + +/// Withholds a serial until the SAME value has been seen twice (#1193). +/// +/// The 5/MG adopts its DIS serial on first read, because `0x2A25` is a spec-defined field that means one +/// thing. The 4.0 offset is not that: it came off a single capture, so "the 9-char alnum run at offset 14" +/// is a strong inference rather than a documented field. If that run were per-session rather than +/// per-strap, adopting immediately would mint a new id on every connect and migrate the history each time +/// - strictly worse than the duplicate row this exists to prevent. +/// +/// Two hellos carrying the same run cannot both be a fresh per-session token, which is the only failure +/// mode that does real damage. The cost is that a 4.0 adopts one connect later than a 5/MG. +/// +/// Lives here, apart from the BLE classes that use it, because it is the part most likely to be wrong and +/// the part neither platform can unit-test in place. +public struct RepeatedSerialGate { + private var candidate: String? + private var confirmed: String? + + public init() {} + + /// The serial to adopt, or nil while it is still unconfirmed. Returns non-nil only on the transition + /// to confirmed, so a caller may act on it directly without re-adopting on every later sighting. + public mutating func offer(_ serial: String) -> String? { + if confirmed == serial { return nil } // already acted on + guard candidate == serial else { + candidate = serial // first sighting: remember, withhold + return nil + } + confirmed = serial + return serial + } +} diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Ecg.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Ecg.swift index 18f7038c37..68fb8d068b 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Ecg.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Ecg.swift @@ -285,16 +285,31 @@ public enum Whoop5Ecg { } /// The `mainControlECGDataGeneration` argument. + /// + /// ⚠️ These raw values are ATTESTED ON ONE DEVICE, and they are NOT the vendor client's declaration + /// order. The previous `stop = 0 / start = 1 / restart = 2` was read off that order and shipped + /// unverified (#896). On a WHOOP MG (`WS50_r00`, fw `50.39.1.0`) each argument was sent on its own + /// while watching the type-43 stream rather than the ack: + /// + /// - `0` is REFUSED — the strap answers `FAILURE(0)` and generation is unchanged, so there is no + /// case for it here and nothing can send it. + /// - `1` STOPS generation. + /// - `2` STARTS it. The type-43 stream only follows once `TOGGLE_LABRADOR_FILTERED (139)` is on, so + /// the working turn-on order is `139 = 1` then `124 = 2`. 139 gates the STREAM rather than the + /// front end: with 139 closed, `124 = 2` still made the strap's own console log + /// `MAX86176: Set ECG ON` while no packets arrived (8 sends, 8 console lines, #891). + /// + /// One device, one firmware. #1100 ran `WS50_r03` / `50.40.1.0` and nothing here says the two agree. + /// Whether `2` is a plain start or a stop-then-start is NOT distinguishable from a stream that was + /// already off, so the case is named for what it achieves rather than for the client's third name. public enum ControlSignal: UInt8, Equatable, Sendable, CaseIterable { - case stop = 0 - case start = 1 - case restart = 2 + case stop = 1 + case start = 2 public var token: String { switch self { case .stop: return "stop" case .start: return "start" - case .restart: return "restart" } } } @@ -367,7 +382,9 @@ public enum Whoop5Ecg { case toggleRealtimeFilteredEcgCmd: return arg != 0 case mainControlEcgDataGenerationCmd: - return arg == ControlSignal.start.rawValue || arg == ControlSignal.restart.rawValue + // Only the START value. `ControlSignal.stop` (1) halts generation, so a run whose last act + // was that one asked for nothing and its silence is the expected outcome, not a finding. + return arg == ControlSignal.start.rawValue default: return false } @@ -508,6 +525,84 @@ public enum Whoop5Ecg { return end <= payload.count && payload.count - end <= maxPadding } + // MARK: - The type-43 REALTIME_RAW_DATA record: the live ECG sample carrier (#891/#1100) + // + // OBSERVED on one WHOOP MG (WS50_r00, fw 50.39.1.0), not attested by any vendor document: once + // TOGGLE_LABRADOR_FILTERED(139)=1 has opened the master gate, the strap emits fixed-size 240-byte + // REALTIME_RAW_DATA (type 43) records whose body carries an i16-LE series. + // + // Twin of the Kotlin `Whoop5Ecg` helpers of the same names. Android grew the ECG app layer that consumes + // these first; the decode is a PROTOCOL fact, so it lands on both platforms together rather than living + // only where it happens to be called today. Nothing here decodes a status field, an HR, or a rhythm + // classification — only the sample series and a byte-fill count. Callers gate on the frame's CRC first. + + /// Every REALTIME_RAW_DATA record OBSERVED on the MG was exactly this long. + public static let rawRecordLength = 240 + + /// Frame offset of the inner record's type byte on 5/MG (`[8]type [9]seq [10]cmd`). + public static let rawTypeOffset = 8 + + /// The inner record type byte for REALTIME_RAW_DATA. + public static let rawRecordType: UInt8 = 43 + + /// First body byte considered by `realtimeRawBodyNonZeroBytes` — excludes the constant sub-header. + public static let rawBodyStart = 24 + + /// First waveform byte. Bytes 24..33 are a constant 5x i16 sub-header that is NOT waveform. + public static let rawWaveformStart = 34 + + /// One past the last body byte; the remaining 4 bytes are the frame's CRC32 trailer. + public static let rawBodyEnd = 236 + + /// Samples one record carries: 101. Load-bearing beyond arithmetic — a fixed sample count per record is + /// exactly the shape that makes autocorrelation manufacture a peak at the record period, so anything + /// estimating a rate from these samples must exclude this lag. See the #194 withdrawal for what + /// happens when that is not done. + public static let samplesPerRawRecord = (rawBodyEnd - rawWaveformStart) / 2 + + /// Non-zero body bytes above which a record is treated as carrying a waveform rather than baseline. + public static let rawBodyActiveNonZeroBytes = 20 + + /// True for a frame shaped like a REALTIME_RAW_DATA record. Shape only — says nothing about CRC. + public static func isRealtimeRawRecord(_ frame: [UInt8]) -> Bool { + frame.count == rawRecordLength && frame[rawTypeOffset] == rawRecordType + } + + /// The record's i16-LE sample series, or nil when `frame` is not a REALTIME_RAW_DATA record. + /// + /// Signed two's-complement, little-endian, exactly `samplesPerRawRecord` values. Zero samples are + /// returned as zeros and never trimmed: for a research artifact a trailing run of zeros is evidence + /// about the record, not padding to be tidied away. + public static func realtimeRawSamples(_ frame: [UInt8]) -> [Int]? { + guard isRealtimeRawRecord(frame) else { return nil } + var out = [Int]() + out.reserveCapacity(samplesPerRawRecord) + var i = rawWaveformStart + while i + 1 < rawBodyEnd { + let raw = Int(frame[i]) | (Int(frame[i + 1]) << 8) + out.append(raw >= 0x8000 ? raw - 0x10000 : raw) + i += 2 + } + return out + } + + /// Non-zero bytes in the body region, or nil when `frame` is not a REALTIME_RAW_DATA record. + public static func realtimeRawBodyNonZeroBytes(_ frame: [UInt8]) -> Int? { + guard isRealtimeRawRecord(frame) else { return nil } + var n = 0 + for i in rawBodyStart.. Bool? { + guard let n = realtimeRawBodyNonZeroBytes(frame) else { return nil } + return n > rawBodyActiveNonZeroBytes + } + /// The frame-level form of `plausibleFilteredPayload`, CRC-gated. This is what the app layer runs /// over unclassified 5/MG frames while an ECG probe is armed. public static func plausibleFilteredFrame(_ frame: [UInt8], diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5RawImu.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5RawImu.swift index bd2230957e..b419231782 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5RawImu.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5RawImu.swift @@ -8,9 +8,9 @@ import Foundation // app captures (documented in Asherlc/dofek `docs/whoop-ble-protocol.md` as the type-0x2B raw packet) // and the same columnar convention as the 4.0's 1917 REALTIME_RAW_DATA variant. // -// IMPORTANT: the 5/MG's *live* raw-IMU stream is firmware-refused (TOGGLE_IMU_MODE / cmd 106 acks but -// never streams), but the OFFLOAD buffer carries full accel AND gyro — so 100 Hz 6-axis IMU IS -// obtainable on the 5.0 via the historical path. +// Hardware validation on a WHOOP 5/MG established that live raw IMU requires START_RAW_DATA (81) +// followed by TOGGLE_IMU_MODE (106) with payload [1,1]. Opcode 106 on its own acknowledges without +// starting the producer. The same 1244-byte shape is also banked and later returned by history sync. // // Frame layout (the reassembled BLE frame = 8-byte puffin envelope + payload; offsets are FRAME-absolute): // @15 u32 LE strap unix seconds for this 1-second frame diff --git a/Packages/WhoopProtocol/Sources/whoop-decode/main.swift b/Packages/WhoopProtocol/Sources/whoop-decode/main.swift index 6a5910717d..a28c79f2b2 100644 --- a/Packages/WhoopProtocol/Sources/whoop-decode/main.swift +++ b/Packages/WhoopProtocol/Sources/whoop-decode/main.swift @@ -20,8 +20,12 @@ import WhoopProtocol // // Options: // --family F whoop4 | whoop5 | auto (default: auto — per-frame from `char`, else whoop5) -// --json emit decoded frames as JSON (ParsedFrame + provenance) instead of a text dump -// --raw-only print only frames that did NOT fully decode (ok=false) — the RE worklist +// --json emit decoded frames as JSON (ParsedFrame + provenance) instead of a text dump. +// Each frame carries `ok` (the FULL integrity verdict) and `rejectReason` (why not); +// a downstream filter that wants only trustworthy frames filters on `ok`. +// --raw-only print only frames that are NOT intact (ok=false) — the RE worklist. Since the +// verdict widened, that includes frames which decoded fine but whose envelope did +// not verify; they are printed WITH their packet type and rejection reason. // -h, --help show this help // MARK: - Input model @@ -57,6 +61,11 @@ Reads a capture/fixture JSON array of {"hex": …} (the capture tool's richer {"hex","char","hr","ts_ms"} records are read too) from FILE or stdin, or raw frames from --hex. Family defaults to auto: derived per-frame from `char` (fd4b…→whoop5, 6108…→whoop4), falling back to whoop5. + +--raw-only selects the frames that are NOT INTACT (ok=false): header checksum, +payload CRC32 or structural length disagreed. Such a frame is still printed with +its decoded packet type and a reason=… field naming which rule it failed. In +--json output the same two values are `ok` and `rejectReason` per frame. """ var familyMode: FamilyMode = .auto @@ -179,7 +188,10 @@ struct DecodedOut: Encodable { var decodedOut: [DecodedOut] = [] var typeCounts: [String: Int] = [:] -var okCount = 0, total = 0 +// INTACT = the verifier's full verdict. PARSED = a packet type came out of the bytes. They are +// different questions since the verdict widened, and the summary reports both rather than one "ok". +var intactCount = 0, parsedCount = 0, total = 0 +var rejectCounts: [FrameRejectReason: Int] = [:] for (n, rec) in records.enumerated() { guard let frame = bytes(fromHex: rec.hex) else { @@ -191,9 +203,14 @@ for (n, rec) in records.enumerated() { // the per-field metadata the live pipeline skips. let parsed = parseFrame(frame, family: family, collectFields: true) total += 1 - if parsed.ok { okCount += 1 } + if parsed.ok { intactCount += 1 } else { rejectCounts[parsed.rejectReason, default: 0] += 1 } + if parsed.isParsable { parsedCount += 1 } typeCounts[parsed.typeName, default: 0] += 1 + // --raw-only is the RE worklist: the frames this tool could NOT fully account for. It filters on + // the integrity verdict, which is what "did not fully decode" now means — a frame with a broken + // envelope belongs on that worklist even though its type read fine, and it is printed WITH its type + // and its rejection reason rather than dropped. if rawOnly && parsed.ok { continue } if jsonOut { @@ -202,9 +219,10 @@ for (n, rec) in records.enumerated() { continue } - // Text dump. - let crc = parsed.crcOK.map { $0 ? "ok" : "BAD" } ?? "—" - var head = "[\(n)] \(family.rawValue) ok=\(parsed.ok) type=\(parsed.typeName) seq=\(parsed.seq.map(String.init) ?? "—") crc=\(crc)" + // Text dump. The frame line itself is built by the library (`frameInspectionLine`) so a test can + // pin the property that a rejected frame keeps its packet type and names its reason; only the + // capture provenance, which the library never sees, is appended here. + var head = frameInspectionLine(index: n, family: family, parsed: parsed) if let c = rec.char { head += " char=\(c)" } if let hr = rec.hr { head += " hr=\(hr)" } print(head) @@ -225,9 +243,17 @@ if jsonOut { } } else { // Summary to stderr so it doesn't pollute a piped dump. - var summary = "\n— \(okCount)/\(total) frames decoded ok —\n" + var summary = "\n— \(intactCount)/\(total) frames intact, \(parsedCount)/\(total) readable —\n" for (t, c) in typeCounts.sorted(by: { $0.value > $1.value }) { summary += " \(pad(String(c), 5, right: true)) \(t)\n" } + // Why the rejected ones were rejected. Never silently omitted, and never overstated: a payload + // CRC32 that could not be computed is reported as `payloadCRCUnverifiable`, not as a mismatch. + if !rejectCounts.isEmpty { + summary += " rejected by reason:\n" + for r in FrameRejectReason.allCases where (rejectCounts[r] ?? 0) > 0 { + summary += " \(pad(String(rejectCounts[r] ?? 0), 5, right: true)) \(r.rawValue)\n" + } + } FileHandle.standardError.write(Data(summary.utf8)) } diff --git a/Packages/WhoopProtocol/Sources/whoop-re/main.swift b/Packages/WhoopProtocol/Sources/whoop-re/main.swift index b4bf4b8084..17a3e7a987 100644 --- a/Packages/WhoopProtocol/Sources/whoop-re/main.swift +++ b/Packages/WhoopProtocol/Sources/whoop-re/main.swift @@ -173,12 +173,18 @@ case "coverage": case "inventory": requireFile() - print(pad("TYPE/VERSION", 24) + pad("COUNT", 8, right: true) + pad("OK", 8, right: true) - + pad("CRC", 8, right: true) + pad("LEN", 12, right: true) + " TS SPAN") + // INTACT (full verdict) and PARSABLE (a packet type was decoded) are separate columns: a capture + // whose frames all read fine but whose envelopes are damaged reads 0 INTACT and n PARSABLE, and + // collapsing that into one "OK" column would hide which of the two is the problem. + print(pad("TYPE/VERSION", 24) + pad("COUNT", 8, right: true) + pad("INTACT", 8, right: true) + + pad("PARSED", 8, right: true) + pad("CRC", 8, right: true) + + pad("LEN", 12, right: true) + " TS SPAN") for g in ReTools.inventory(loadRecords(positional[0], family)) { let lenCol = g.minLen == g.maxLen ? "\(g.minLen)" : "\(g.minLen)-\(g.maxLen)" let span = (g.firstTsMs != nil && g.lastTsMs != nil) ? "\(g.firstTsMs!)..\(g.lastTsMs!)" : "—" - print(pad(g.key, 24) + pad(String(g.count), 8, right: true) + pad(String(g.okCount), 8, right: true) + print(pad(g.key, 24) + pad(String(g.count), 8, right: true) + + pad(String(g.intactCount), 8, right: true) + + pad(String(g.parsableCount), 8, right: true) + pad(String(g.crcOkCount), 8, right: true) + pad(lenCol, 12, right: true) + " \(span)") } diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/BatteryPackInfoTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/BatteryPackInfoTests.swift index 814b67d192..b1a2e10e99 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/BatteryPackInfoTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/BatteryPackInfoTests.swift @@ -73,4 +73,81 @@ final class BatteryPackInfoTests: XCTestCase { XCTAssertNil(BatteryPackInfo.decodeExtended(frame: bytes(attachedHex))) XCTAssertNil(BatteryPackInfo.decode(frame: bytes(attachedHex))?.voltageMv) } + + /// The gauge must be sanity-checked before it is shown. These offsets are an unvalidated candidate + /// re-derived from two captures; a wrong one does not fail, it renders a confident wrong number — the + /// failure this project treats as worse than a blank. A percentage outside 0...100 means the offset + /// moved, so the caller renders nothing. + func testOutOfRangeChargeIsNotDisplayable() { + XCTAssertFalse(BatteryPackInfo.Info(present: true, socPct: 2488.1, serial: "P", btAddr: "aa").displayable) + XCTAssertFalse(BatteryPackInfo.Info(present: true, socPct: -1, serial: "P", btAddr: "aa").displayable) + } + + /// A plausible gauge on an attached pack is the one case that shows. + func testInRangeChargeOnAnAttachedPackIsDisplayable() { + XCTAssertTrue(BatteryPackInfo.Info(present: true, socPct: 73.4, serial: "P", btAddr: "aa").displayable) + XCTAssertTrue(BatteryPackInfo.Info(present: true, socPct: 0, serial: "P", btAddr: "aa").displayable) + XCTAssertTrue(BatteryPackInfo.Info(present: true, socPct: 100, serial: "P", btAddr: "aa").displayable) + } + + /// A removed pack must clear the card, never hold the last reading. + func testAbsentPackIsNeverDisplayable() { + XCTAssertFalse(BatteryPackInfo.Info(present: false, socPct: nil, serial: nil, btAddr: nil).displayable) + // Even if a stale charge rides along, absence wins. + XCTAssertFalse(BatteryPackInfo.Info(present: false, socPct: 80, serial: nil, btAddr: nil).displayable) + } + + /// The router branch that decodes this reply matches on the command NAME + /// (`cmdName.hasPrefix("GET_BATTERY_PACK_INFO(")`), which comes from the schema's CommandNumber table. + /// A rename or a removal there would not fail to compile — the branch would simply stop matching and + /// the whole feature would go quiet, with the decoder back to having no caller. Pin the string. + func testCommand151ResolvesToTheNameTheRouterMatchesOn() { + XCTAssertEqual(loadSchema().enumName("CommandNumber", 151), "GET_BATTERY_PACK_INFO(151)") + } + + /// `displayable` is about a CHARGE percentage, and the 4.0 path carries a voltage instead — so it is + /// always false there, correctly. Pinned because the name is generic enough that a future 4.0 voltage + /// card might reach for this gate and get a permanent no. + func testFourPointOhVoltagePathIsNeverDisplayable() { + let v = BatteryPackInfo.Info(present: true, socPct: nil, serial: nil, btAddr: nil, voltageMv: 3_900) + XCTAssertFalse(v.displayable) + } + + /// REAL HARDWARE vectors: the pack record as the strap actually delivers it — the payload of the + /// uncatalogued pushed event 109, captured from a WHOOP MG on fw 50.39.1.0 (2026-09-01). Command 151 + /// answers FAILURE(0) on that firmware whatever you send it, so the event is the transport the feature + /// runs on. The Kotlin twin asserts the same values. + private let packEvent569 = "f5481c000100005e005301574242354150303030303030310000003902010c00" + private let packEvent567 = "1e451c000100005e005301574242354150303030303030310000003702010c00" + + func testPushedPackEventCarriesTheSameRecordAsTheCommandReply() { + let info = BatteryPackInfo.decodeEventPayload(bytes(packEvent569)) + XCTAssertEqual(info?.present, true) + XCTAssertEqual(info?.serial, "WBB5AP0000001") + XCTAssertEqual(info?.btAddr, "00005e005301") + XCTAssertEqual(info?.socPct ?? -1, 56.9, accuracy: 1e-9) + XCTAssertNil(info?.voltageMv) // the event never carries a voltage + } + + /// The SoC field TRACKS A VARYING INPUT rather than merely looking plausible once: these two records are + /// minutes apart while the pack drains into the strap, and the value falls. A single reading cannot tell + /// tenths-of-a-percent from mAh; a falling pair in the physically correct direction, both inside 0...100 + /// when read as tenths, is what settles the unit. + func testPackSocFallsAsThePackDrainsIntoTheStrap() { + let first = BatteryPackInfo.decodeEventPayload(bytes(packEvent569))?.socPct ?? -1 + let later = BatteryPackInfo.decodeEventPayload(bytes(packEvent567))?.socPct ?? -1 + XCTAssertEqual(first, 56.9, accuracy: 1e-9) + XCTAssertEqual(later, 56.7, accuracy: 1e-9) + XCTAssertLessThan(later, first, "pack SoC must fall while discharging into the strap") + XCTAssertLessThanOrEqual(first, 100.0) + XCTAssertGreaterThanOrEqual(later, 0.0) + } + + /// The command path and the event path must agree field-for-field — they share `decodeRecord`, and a + /// refactor letting them drift would silently give the Devices card two different pack readings. + func testCommandAndEventPathsShareOneRecordDecoder() { + let viaCommand = BatteryPackInfo.decode(frame: bytes(attachedHex)) + let viaRecord = BatteryPackInfo.decodeRecord(bytes(attachedHex), base: 14) // cmdOff 10 + 4 + XCTAssertEqual(viaCommand, viaRecord) + } } diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DataRangeTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DataRangeTests.swift index 18a7058c38..0d97519c24 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DataRangeTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DataRangeTests.swift @@ -126,4 +126,104 @@ final class DataRangeTests: XCTestCase { "pagesBehind for \(h) at cmdOff \(cmdOff) should be \(expected)") } } + + // MARK: - #689 PENDING ack. Byte-parity twin of the Kotlin DataRangeScanTest cases. + // + // GET_DATA_RANGE answers twice and only the second carries the ring pointers. Both frames below are + // real, captured on a WHOOP MG (WS50_r00, fw 50.39.1.0) on 2026-08-28, and they are one request's + // two replies. + + /// The PENDING(2) ack: 20 bytes, result byte at cmdOff+2 is 0x02, no ring pointers in it at all. + func testIsPendingResponse_trueForPendingAck() { + XCTAssertTrue(DataRange.isPendingResponse(hex("aa010c000100271124e8220402000000c391bc3d"), cmdOff: 10)) + } + + /// The SUCCESS(1) answer to that same request. Must NOT be skipped. + func testIsPendingResponse_falseForSuccessAnswer() { + let h = "aa014c00010032d124e92204010100bf0000b4be0000c1be0000b4be00000c000000000002008f00" + XCTAssertFalse(DataRange.isPendingResponse(hex(h), cmdOff: 10)) + } + + /// The reason this pair matters: the ack decodes to nil, so before the guard it logged as a decode + /// failure once per sync while the real answer decoded fine two lines later. + func testPendingAckIsExactlyTheFramePagesBehindCannotDecode() { + let pending = hex("aa010c000100271124e8220402000000c391bc3d") + XCTAssertNil(DataRange.pagesBehind(from: pending, cmdOff: 10)) + XCTAssertTrue(DataRange.isPendingResponse(pending, cmdOff: 10)) + } + + /// A frame too short to hold a result byte is not a PENDING ack; the caller keeps its own handling. + func testIsPendingResponse_falseWhenTooShortForResultByte() { + XCTAssertFalse(DataRange.isPendingResponse([UInt8](repeating: 0, count: 11), cmdOff: 10)) + } + + /// A negative cmdOff is rejected rather than indexing backwards. + func testIsPendingResponse_falseForNegativeCmdOff() { + XCTAssertFalse(DataRange.isPendingResponse([UInt8](repeating: 0, count: 40), cmdOff: -1)) + } + + // MARK: - the BLE seam's gate on this reply (task 2.15) + + /// GET_DATA_RANGE(34) — the opcode the seam compares at `cmdOff`, held by the app target's command + /// table; the predicate takes it as an argument rather than restating the protocol fact. + private let getDataRangeOpcode: UInt8 = 34 + + /// A complete WHOOP 4.0 GET_DATA_RANGE COMMAND_RESPONSE: type@4, seq@5, cmd@6, body from 7 — + /// `oldest` on the aligned grid `oldestUnix` scans, `newest` one word later. + private func dataRangeReply(oldest: Int, newest: Int) -> [UInt8] { + var payload = [UInt8]() + for v in [oldest, newest] { for k in 0..<4 { payload.append(UInt8((v >> (8 * k)) & 0xFF)) } } + return frameFromPayload(payload, type: 0x24, seq: 1, cmd: getDataRangeOpcode) + } + + /// An intact reply is accepted, and it is the value the seam then applies as the reported window + /// and as the offload's plausibility bounds. + func testAnIntactDataRangeReplyIsAcceptedAndCarriesTheWindow() { + let oldest = 1_750_000_000, newest = 1_780_000_000 + let f = dataRangeReply(oldest: oldest, newest: newest) + let p = parseFrame(f, family: .whoop4) + XCTAssertTrue(p.ok, "precondition: the reply is intact") + XCTAssertTrue(DataRange.acceptsReply(f, cmdOff: 6, opcode: getDataRangeOpcode, verdictOK: p.ok)) + XCTAssertEqual(DataRange.newestUnix(from: f, wallNowUnix: 1_790_000_000, futureSkewSeconds: skew48h), + newest) + XCTAssertEqual(DataRange.oldestUnix(from: f), oldest) + } + + /// Scenario "eine Antwort mit falscher Kopfprüfsumme verändert weder den gemeldeten Zeitbereich + /// noch die Plausibilitätsgrenzen des Abzugs" (task 2.15). The damaged reply still DECODES a + /// window — the verdict is the only thing that keeps it out, which is why the gate has to be a + /// predicate a test can hold rather than a condition buried in the seam. + func testABrokenDataRangeReplyMovesNeitherTheWindowNorThePlausibilityBounds() { + let oldest = 1_750_000_000, newest = 1_780_000_000 + var bad = dataRangeReply(oldest: oldest, newest: newest) + bad[3] ^= 0xFF // CRC-8 over the length field only + let p = parseFrame(bad, family: .whoop4) + XCTAssertEqual(p.rejectReason, .headerChecksumMismatch) + XCTAssertEqual(p.crcOK, true, "precondition: the payload CRC32 still verifies") + XCTAssertEqual(DataRange.newestUnix(from: bad, wallNowUnix: 1_790_000_000, futureSkewSeconds: skew48h), + newest, "precondition: these bytes DO carry a window a seam could apply") + + // The seam in miniature: the reported window and the two plausibility bounds, moved only on + // an accepted reply. Their previous values must survive a damaged one untouched. + var reportedNewest: Int? = 1_700_000_001 + var boundsNewest: Int? = 1_700_000_001 + var boundsOldest: Int? = 1_700_000_000 + if DataRange.acceptsReply(bad, cmdOff: 6, opcode: getDataRangeOpcode, verdictOK: p.ok) { + reportedNewest = DataRange.newestUnix(from: bad, wallNowUnix: 1_790_000_000, + futureSkewSeconds: skew48h) + boundsNewest = reportedNewest + boundsOldest = DataRange.oldestUnix(from: bad) + } + XCTAssertEqual(reportedNewest, 1_700_000_001, "the reported range must not move") + XCTAssertEqual(boundsNewest, 1_700_000_001, "the offload's upper plausibility bound must not move") + XCTAssertEqual(boundsOldest, 1_700_000_000, "the offload's lower plausibility bound must not move") + } + + /// The gate is per-reply, not per-connection: a frame carrying another opcode at `cmdOff` is not a + /// data-range reply at all, however intact it is. + func testAnotherOpcodeIsNotADataRangeReply() { + let other = frameFromPayload([0x00, 0x01, 0x02, 0x03], type: 0x24, seq: 1, cmd: 26) + XCTAssertTrue(parseFrame(other, family: .whoop4).ok) + XCTAssertFalse(DataRange.acceptsReply(other, cmdOff: 6, opcode: getDataRangeOpcode, verdictOK: true)) + } } diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift index 0151818b65..dde098a781 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift @@ -505,4 +505,38 @@ final class DeviceConfigReadProbeTests: XCTestCase { """ XCTAssertEqual(report.render(), golden) } + + // MARK: - direct caller of the verifier (2.5): the useful path is unchanged + + /// `DeviceConfigReadProbe.parse` gates on `verifyFrame` directly. The ordinary replies must still + /// decode under the tightened verifier, and the newly-rejected classes must come back as its own + /// `.crc` refusal instead of being read. + func testTheOrdinaryReplyStillDecodesUnderTheTightenedVerifier() { + let record = echoRecord("enable_r22_packets", value: 0x32, lead: [0x01]) + let five = whoop5Response(cmd: 128, payload: payload(result: 1, record: record)) + XCTAssertEqual(verifyFrame(five, family: .whoop5).reason, .none, "precondition: intact") + guard case .success(let r) = DeviceConfigReadProbe.parse(frame: five, family: .whoop5, expecting: 128) else { + return XCTFail("the 5/MG useful path must still decode") + } + XCTAssertEqual(r.value(for: "enable_r22_packets"), 0x32) + + let four = whoop4Response(cmd: 121, payload: payload(result: 1, record: echoRecord("k", value: 0x31))) + XCTAssertEqual(verifyFrame(four, family: .whoop4).reason, .none) + guard case .success = DeviceConfigReadProbe.parse(frame: four, family: .whoop4, expecting: 121) else { + return XCTFail("the 4.0 useful path must still decode") + } + } + + func testAReplyWithTrailingBytesIsRefusedNotRead() { + let frame = whoop4Response(cmd: 121, payload: payload(result: 1, record: echoRecord("k", value: 0x31))) + [0x00] + XCTAssertEqual(verifyFrame(frame, family: .whoop4).reason, .lengthMismatch) + XCTAssertEqual(DeviceConfigReadProbe.parse(frame: frame, family: .whoop4, expecting: 121), .failure(.crc)) + } + + func testAReplyWithABrokenHeaderChecksumIsRefusedNotRead() { + var frame = whoop4Response(cmd: 121, payload: payload(result: 1, record: echoRecord("k", value: 0x31))) + frame[3] ^= 0xFF + XCTAssertEqual(verifyFrame(frame, family: .whoop4).crc32OK, true, "the payload CRC32 still verifies") + XCTAssertEqual(DeviceConfigReadProbe.parse(frame: frame, family: .whoop4, expecting: 121), .failure(.crc)) + } } diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FastPathParityTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FastPathParityTests.swift index 16c39bb0ae..c9cad4a67a 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FastPathParityTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FastPathParityTests.swift @@ -133,7 +133,8 @@ final class FastPathParityTests: XCTestCase { /// a full `parseFrame` on TWO things, or the pre-filter's `guard frameTypeName == "EVENT"` could diverge /// from the full-parse `guard typeName == "EVENT"` — over-admitting (a wasted parse) or, dangerously, /// DROPPING a real gesture: - /// 1. for a VALID frame, the exact type name matches; + /// 1. for a frame that decodes at all, the exact type name matches (the peek says nothing about + /// INTEGRITY — `parseFrame.ok` is the verifier's verdict, and the two deliberately differ there); /// 2. for ANY frame (incl. malformed/short/wrong-SOF), the EVENT *decision* matches — this is the /// property the pre-filter actually rests on, and it must hold even where the two disagree on the /// name (frameTypeName returns nil for a short frame; parseFrame calls it "INVALID/FRAGMENT" — both @@ -142,7 +143,9 @@ final class FastPathParityTests: XCTestCase { func check(_ frame: [UInt8], _ family: DeviceFamily, _ label: String) { let peek = frameTypeName(frame, family: family) let full = parseFrame(frame, family: family) - if full.ok { + // Compared on PARSABILITY, not on the integrity verdict: `ok` now reports whether the + // frame is intact, and a corrupt frame still decodes a type name the peek must match. + if full.typeName != "INVALID/FRAGMENT" { XCTAssertEqual(peek, full.typeName, "frameTypeName != parseFrame.typeName at \(label)") } // The contract the offload gesture pre-filter depends on: never drop/over-admit an EVENT. @@ -156,9 +159,9 @@ final class FastPathParityTests: XCTestCase { // Malformed / boundary frames the corpus doesn't carry, where a false-negative would silently drop a // gesture on real BLE (fragments, corruption). Both peek and full parse must land on "not EVENT". check([], .whoop4, "empty") - check([0xAA, 0x01, 0x02], .whoop4, "short (below the 8-byte min)") + check([0xAA, 0x01, 0x02], .whoop4, "short (below the 11-byte min)") check([0x00, 0x18, 0x00, 0xff, 0x28, 0x02, 0x0f, 0x00], .whoop4, "wrong SOF") - check(hexToBytes("aa010c000001"), .whoop5, "short 5/MG (below the 12-byte min)") + check(hexToBytes("aa010c000001"), .whoop5, "short 5/MG (below the 13-byte min)") } /// The DEFAULT call is the fast path on both entry points, so every live/offload call site diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FeatureFlagProbeTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FeatureFlagProbeTests.swift index 4b21687df5..137e99c186 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FeatureFlagProbeTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FeatureFlagProbeTests.swift @@ -870,4 +870,37 @@ final class FeatureFlagProbeTests: XCTestCase { XCTAssertTrue(v.contains("an implausible 9999 flag(s)"), v) XCTAssertTrue(v.contains("(inconclusive)"), v) } + + // MARK: - direct caller of the verifier (2.5): the useful path is unchanged + + /// `FeatureFlagProbe.record` calls `verifyFrame` itself rather than going through `parseFrame`, so a + /// stricter verifier could have silently broken this probe. These pin that it did not — the ordinary + /// well-formed replies of BOTH families still decode — and that the classes the verifier newly + /// rejects come back as `.crc`, its own refusal, rather than being read as data. + func testTheOrdinaryReplyStillDecodesUnderTheTightenedVerifier() { + let four = whoop4Response(cmd: 117, payload: payload(result: 1, record: [0x01, 0x0B, 0x00])) + XCTAssertTrue(verifyFrame(four, family: .whoop4).ok, "precondition: an ordinary 4.0 reply is intact") + XCTAssertEqual(verifyFrame(four, family: .whoop4).reason, .none) + guard case .success = FeatureFlagProbe.parseStart(frame: four, family: .whoop4) else { + return XCTFail("the 4.0 useful path must still decode") + } + let five = whoop5Response(cmd: 117, payload: payload(result: 1, record: [0x01, 0x10, 0x00])) + XCTAssertTrue(verifyFrame(five, family: .whoop5).ok) + guard case .success = FeatureFlagProbe.parseStart(frame: five, family: .whoop5) else { + return XCTFail("the 5/MG useful path must still decode") + } + } + + func testAReplyWithTrailingBytesIsRefusedNotRead() { + let frame = whoop4Response(cmd: 117, payload: payload(result: 1, record: [0x01, 0x0B, 0x00])) + [0x00] + XCTAssertEqual(verifyFrame(frame, family: .whoop4).reason, .lengthMismatch) + XCTAssertEqual(FeatureFlagProbe.parseStart(frame: frame, family: .whoop4), .failure(.crc)) + } + + func testAReplyWithABrokenHeaderChecksumIsRefusedNotRead() { + var frame = whoop4Response(cmd: 117, payload: payload(result: 1, record: [0x01, 0x0B, 0x00])) + frame[3] ^= 0xFF + XCTAssertEqual(verifyFrame(frame, family: .whoop4).crc32OK, true, "the payload CRC32 still verifies") + XCTAssertEqual(FeatureFlagProbe.parseStart(frame: frame, family: .whoop4), .failure(.crc)) + } } diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FrameDiagnosticsTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FrameDiagnosticsTests.swift new file mode 100644 index 0000000000..b16ac106a9 --- /dev/null +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FrameDiagnosticsTests.swift @@ -0,0 +1,218 @@ +import XCTest +@testable import WhoopProtocol + +/// Scenario group "Diagnose behält Parsbarkeit und Ablehnungsgrund", plus the two counters D3 asks for. +/// +/// The frames are the same corpus-derived fixtures the integrity tests use, each broken in exactly ONE +/// way, so every assertion below attributes a single cause. +final class FrameDiagnosticsTests: XCTestCase { + + private func hex(_ s: String) -> [UInt8] { FrameIntegrityTests.hex(s) } + + /// A real WHOOP 4.0 REALTIME_DATA frame with ONLY its header checksum broken: the class that + /// passed every gate before this change. + private func headerBroken() -> [UInt8] { + var f = hex(FrameIntegrityTests.w4Valid) + f[3] ^= 0xFF + return f + } + + // MARK: - parseability is not integrity + + func testARejectedFrameKeepsItsPacketType() { + let p = parseFrame(headerBroken()) + XCTAssertFalse(p.ok) + XCTAssertTrue(p.isParsable, "the decoder read this frame; only the envelope failed") + XCTAssertEqual(p.typeName, "REALTIME_DATA") + } + + func testAByteRunThatIsNotAFrameIsNotParsable() { + let p = parseFrame([0xAA, 0x00, 0x00]) + XCTAssertFalse(p.isParsable) + XCTAssertEqual(p.typeName, ParsedFrame.unparsableTypeName) + } + + func testAnIntactFrameIsBothIntactAndParsable() { + let p = parseFrame(hex(FrameIntegrityTests.w4Valid)) + XCTAssertTrue(p.ok) + XCTAssertTrue(p.isParsable) + XCTAssertEqual(p.rejectReason, .none) + } + + // MARK: - the inspector's line keeps type AND names the reason + + func testInspectionLineOfARejectedFrameCarriesTypeAndReason() { + let line = frameInspectionLine(index: 7, family: .whoop4, parsed: parseFrame(headerBroken())) + XCTAssertTrue(line.contains("ok=false"), line) + XCTAssertTrue(line.contains("type=REALTIME_DATA"), "a rejected frame must keep its type: \(line)") + XCTAssertTrue(line.contains("reason=headerChecksumMismatch"), line) + // The payload CRC32 verified — the line says so rather than smearing one failure over both. + XCTAssertTrue(line.contains("crc=ok"), line) + } + + func testInspectionLineOfAnIntactFrameCarriesNoReason() { + let line = frameInspectionLine(index: 0, family: .whoop4, + parsed: parseFrame(hex(FrameIntegrityTests.w4Valid))) + XCTAssertTrue(line.contains("ok=true"), line) + XCTAssertFalse(line.contains("reason="), "an intact frame has no reason to report: \(line)") + } + + /// Scenario "Nicht prüfbar wird nicht als falsch gemeldet": a payload CRC32 that could not be + /// computed is reported as unverifiable, and the CRC column stays "—" rather than claiming "BAD". + func testUnverifiablePayloadCRCIsNotReportedAsAMismatch() { + let p = parseFrame(hex(FrameIntegrityTests.w4Total8)) + XCTAssertNil(p.crcOK) + XCTAssertNotEqual(p.rejectReason, .payloadCRCMismatch) + let line = frameInspectionLine(index: 1, family: .whoop4, parsed: p) + XCTAssertTrue(line.contains("crc=—"), line) + XCTAssertFalse(line.contains("crc=BAD"), "we did not observe a mismatch, so we do not report one") + } + + // MARK: - the capture export keeps the type and gains the reason + + func testCaptureRecordKeepsThePacketTypeOfARejectedFrame() { + // A 5/MG frame with only its header checksum broken. + var frame = hex(FrameIntegrityTests.w5Valid) + frame[6] ^= 0xFF + let capture = PuffinCapture() + let rec = capture.record(frame: frame, char: "fd4b0005", tsMs: 1234, hr: 60) + XCTAssertFalse(rec.ok) + XCTAssertEqual(rec.typeName, "COMMAND_RESPONSE", + "a capture exists to map unexplained frames; blanking the type throws that away") + XCTAssertEqual(rec.rejectReason, .headerChecksumMismatch) + XCTAssertEqual(rec.crcOK, true) + } + + func testCaptureRecordOfAnIntactFrameCarriesNoReason() { + let capture = PuffinCapture() + let rec = capture.record(frame: hex(FrameIntegrityTests.w5Valid), char: "fd4b0005", + tsMs: 1, hr: nil) + XCTAssertTrue(rec.ok) + XCTAssertEqual(rec.rejectReason, .none) + } + + func testCaptureJSONCarriesTheReasonAndOlderCapturesStillDecode() throws { + var frame = hex(FrameIntegrityTests.w5Valid) + frame[6] ^= 0xFF + let capture = PuffinCapture() + capture.record(frame: frame, char: "fd4b0005", tsMs: 1234, hr: nil) + let json = try XCTUnwrap(String(data: try capture.encodedJSON(), encoding: .utf8)) + XCTAssertTrue(json.contains("\"reject_reason\" : \"headerChecksumMismatch\""), json) + XCTAssertTrue(json.contains("\"type_name\""), "the packet type stays in the export") + + // A record written before the key existed must still decode — additive, not breaking. + let legacy = """ + [{"hex":"aa","char":"fd4b0005","ts_ms":1,"type_name":"EVENT","seq":2,"crc_ok":true,"ok":true}] + """ + let decoded = try JSONDecoder().decode([PuffinCaptureRecord].self, from: Data(legacy.utf8)) + // Spelled out: a bare `.none` here would resolve to `Optional.none`, not to the reason. + XCTAssertEqual(decoded.first?.rejectReason, FrameRejectReason.none) + XCTAssertEqual(decoded.first?.typeName, "EVENT") + } + + // MARK: - counters (D3) + + func testTallyCountsEachReasonSeparately() { + var tally = FrameRejectTally() + tally.note(parseFrame(headerBroken())) + tally.note(parseFrame(hex(FrameIntegrityTests.w4Valid) + [0x00])) // trailing byte + tally.note(parseFrame(hex(FrameIntegrityTests.w4Total8))) // below the 11-byte floor + tally.note(parseFrame(hex(FrameIntegrityTests.w4Valid))) // intact: not counted + XCTAssertEqual(tally.count(.headerChecksumMismatch), 1) + XCTAssertEqual(tally.count(.lengthMismatch), 1) + XCTAssertEqual(tally.count(.belowMinimumLength), 1) + XCTAssertEqual(tally.count(.none), 0, "an intact frame is never counted as a rejection") + XCTAssertEqual(tally.totalRejected, 3) + } + + func testTallyReportsUnverifiableSeparatelyFromMismatch() { + var tally = FrameRejectTally() + // Payload byte flipped: the CRC32 was computed and disagreed. + var wrongPayload = hex(FrameIntegrityTests.w4Valid) + wrongPayload[10] ^= 0xFF + tally.note(parseFrame(wrongPayload)) + XCTAssertEqual(tally.count(.payloadCRCMismatch), 1) + XCTAssertEqual(tally.count(.payloadCRCUnverifiable), 0, + "\"could not be checked\" and \"was wrong\" are different claims") + } + + /// Scenario "Die vorher durchgelassene Klasse ist einzeln ablesbar" (2.16) — the ONE counter the + /// hardware run's abort criterion is read from. A per-reason bucket cannot express it: the length + /// bucket also collects the harmless resyncs after a lost notification. + func testThePreviouslyAdmittedClassHasItsOwnCounter() { + var tally = FrameRejectTally() + tally.note(parseFrame(headerBroken())) // header wrong, CRC32 right + tally.note(parseFrame(hex(FrameIntegrityTests.w4Valid) + [0x00])) // length wrong, CRC32 right + var wrongPayload = hex(FrameIntegrityTests.w4Valid) + wrongPayload[10] ^= 0xFF + tally.note(parseFrame(wrongPayload)) // CRC32 wrong: NOT the class + tally.note(parseFrame(hex(FrameIntegrityTests.w4Total8))) // CRC32 unverifiable: not it + XCTAssertEqual(tally.payloadCRCOKButEnvelopeRejected, 2) + XCTAssertEqual(tally.totalRejected, 4, "the class is counted BESIDE its reason, not instead of it") + } + + /// The offload path never parses its frames at the BLE seam — it hands them straight to the + /// Backfiller — so the parse-result overload cannot count them, and the ONE counter the hardware + /// run's abort criterion reads was blind for exactly the traffic in which the loss occurs. Counting + /// from the verifier's own result closes that without a second parse. + func testTheVerdictOverloadCountsTheSameReasonAndClassAsTheParseResult() { + let broken = headerBroken() + var fromParse = FrameRejectTally() + fromParse.note(parseFrame(broken, family: .whoop4)) + var fromVerdict = FrameRejectTally() + let reason = fromVerdict.note(verifyFrame(broken, family: .whoop4)) + XCTAssertEqual(reason, .headerChecksumMismatch) + XCTAssertEqual(fromVerdict, fromParse, "the two routes into the tally must agree exactly") + XCTAssertEqual(fromVerdict.payloadCRCOKButEnvelopeRejected, 1, + "envelope rejected while the payload CRC32 verified — the abort-criterion class") + } + + /// The same three separations the parse-result overload keeps: an intact frame is not counted, a + /// wrong payload CRC32 is not the named class, and an unverifiable one is neither. + func testTheVerdictOverloadKeepsTheClassSeparations() { + var tally = FrameRejectTally() + XCTAssertEqual(tally.note(verifyFrame(hex(FrameIntegrityTests.w4Valid), family: .whoop4)), .none) + XCTAssertEqual(tally.totalRejected, 0, "an intact frame is never counted as a rejection") + var wrongPayload = hex(FrameIntegrityTests.w4Valid) + wrongPayload[10] ^= 0xFF + tally.note(verifyFrame(wrongPayload, family: .whoop4)) + tally.note(verifyFrame(hex(FrameIntegrityTests.w4Total8), family: .whoop4)) + XCTAssertEqual(tally.count(.payloadCRCMismatch), 1) + XCTAssertEqual(tally.count(.belowMinimumLength), 1) + XCTAssertEqual(tally.payloadCRCOKButEnvelopeRejected, 0, + "neither a wrong nor an uncomputable payload CRC32 is the admitted class") + } + + /// Scenario "Vom Zusammensetzer verworfene Byteläufe sind gesondert erfasst": a byte run the + /// reassembler drops never reaches a parser — and never reaches the evidence-preserving reader — + /// so its disappearance is only visible if it is counted. + func testReassemblerDropsAreCountedAsBelowMinimumLength() { + let r = Reassembler(family: .whoop4) + // A start-of-frame declaring a total of 8 bytes (below the 11-byte floor), then a real frame. + let runt: [UInt8] = [0xAA, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + let good = hex(FrameIntegrityTests.w4Valid) + let out = r.feed(runt + good) + XCTAssertEqual(out.count, 1, "the stream resyncs and still yields the real frame") + XCTAssertGreaterThan(r.belowMinimumLengthDrops, 0, "precondition: the runt was dropped") + + var tally = FrameRejectTally() + tally.absorbReassemblerDrops(r.belowMinimumLengthDrops) + XCTAssertEqual(tally.count(.belowMinimumLength), r.belowMinimumLengthDrops) + // The counter is monotonic, so folding it again must not double-count. + tally.absorbReassemblerDrops(r.belowMinimumLengthDrops) + XCTAssertEqual(tally.count(.belowMinimumLength), r.belowMinimumLengthDrops) + } + + func testSummaryLineIsSilentWhenNothingWasRejected() { + var tally = FrameRejectTally() + tally.note(parseFrame(hex(FrameIntegrityTests.w4Valid))) + XCTAssertNil(tally.summaryLine(), "no \"0 rejections\" line to read past") + } + + func testSummaryLineNamesOnlyTheReasonsThatOccurred() { + var tally = FrameRejectTally() + tally.note(parseFrame(headerBroken())) + let line = try? XCTUnwrap(tally.summaryLine()) + XCTAssertEqual(line, "frameReject total=1 headerChecksumMismatch=1 payloadCRCOKButEnvelopeRejected=1") + } +} diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FrameIntegrityOracleTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FrameIntegrityOracleTests.swift new file mode 100644 index 0000000000..0c918dfb23 --- /dev/null +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FrameIntegrityOracleTests.swift @@ -0,0 +1,203 @@ +import XCTest +@testable import WhoopProtocol + +/// SHARED FRAME-INTEGRITY ORACLE — the Swift half of a Swift <-> Kotlin drift guard for the +/// `frame-integrity` capability (requirement "Plattformgleichheit des Integritätsurteils", D5). +/// +/// `Resources/frame_integrity_oracle.json` holds one line per frame with the three values the two +/// platforms must agree on: the verifier's FULL verdict, the rejection reason, and the historical +/// metadata classification. The IDENTICAL file is committed at +/// `android/app/src/test/resources/frame_integrity_oracle.json`, where `FrameIntegrityOracleTest.kt` +/// asserts the same lines through the Kotlin `Framing.parseFrame` / `classifyHistoricalMeta`. +/// +/// Why a shared file rather than two per-platform suites: both platforms already had their own +/// integrity tests, and both were green while the Kotlin ECG payload path still applied the older, +/// looser envelope bounds. Each suite asserted its own answer, so nothing compared the two answers. +/// This does, over every case and every field. +/// +/// The file is GENERATED by a standalone Swift run +/// (`openspec/changes/frame-integrity/oracle-gen`) — never hand-written, so no expected value here is +/// somebody's belief about what the verifier does. Regenerate it rather than editing it. +/// +/// Deliberately NOT pinned: the export spelling of a reason. The Kotlin capture writer omits the +/// `reject_reason` key when there is no reason, Swift's `Codable` always writes it. That is a textual +/// difference in a different artefact; the oracle pins the judgement, not the serialisation. +/// +/// Deliberately absent: a `payloadCRCUnverifiable` line. The structural rules run first on BOTH +/// platforms, so no byte run can reach that branch — it is defence in depth, and inventing a case for +/// it would mean inventing an input that cannot exist. +final class FrameIntegrityOracleTests: XCTestCase { + + private struct Oracle: Decodable { + let coverage: Coverage + let cases: [OracleCase] + } + + private struct Coverage: Decodable { + let caseCount: Int + let classes: [String: Int] + let reasons: [String: Int] + private enum CodingKeys: String, CodingKey { + case caseCount = "case_count" + case classes, reasons + } + } + + private struct OracleCase: Decodable { + let name: String + let family: String + let klass: String + let hex: String + let verdict: Bool + let rejectReason: String + let meta: String + let ecgPath: Bool + let ecgInnerPayloadHex: String? + private enum CodingKeys: String, CodingKey { + case name, family, hex, verdict, meta + case klass = "class" + case rejectReason = "reject_reason" + case ecgPath = "ecg_path" + case ecgInnerPayloadHex = "ecg_inner_payload_hex" + } + } + + private static let oracleResource = "frame_integrity_oracle" + private static let androidCopy = "android/app/src/test/resources/frame_integrity_oracle.json" + + private func loadOracle() throws -> Oracle { + let url = try XCTUnwrap(Bundle.module.url(forResource: Self.oracleResource, withExtension: "json"), + "frame_integrity_oracle.json missing from the test bundle") + return try JSONDecoder().decode(Oracle.self, from: Data(contentsOf: url)) + } + + private func bytes(_ hex: String) -> [UInt8] { + var out: [UInt8] = [] + var i = hex.startIndex + while i < hex.endIndex { + let j = hex.index(i, offsetBy: 2) + out.append(UInt8(hex[i.. String { + b.map { String(format: "%02x", $0) }.joined() + } + + private func family(_ name: String) throws -> DeviceFamily { + switch name { + case "whoop4": return .whoop4 + case "whoop5": return .whoop5 + default: + XCTFail("unknown family \(name) in the oracle") + throw XCTSkip("unknown family") + } + } + + /// The classification rendered exactly as the generator renders it, so one string compares three + /// possible shapes (case, and for an end its unix + trim) without a second decoding rule. + private func metaLabel(_ p: ParsedFrame) -> String { + switch classifyHistoricalMeta(p) { + case .start: return "start" + case .complete: return "complete" + case .other: return "other" + case .end(let unix, let trim): return "end(unix=\(unix),trim=\(trim))" + } + } + + // MARK: - the three pinned fields, every case + + func testEveryOracleCaseMatchesTheSharedExpectation() throws { + let oracle = try loadOracle() + XCTAssertFalse(oracle.cases.isEmpty, "an empty oracle would assert nothing") + for c in oracle.cases { + let frame = bytes(c.hex) + let parsed = parseFrame(frame, family: try family(c.family)) + XCTAssertEqual(parsed.ok, c.verdict, "verdict for \(c.name)") + XCTAssertEqual(parsed.rejectReason.rawValue, c.rejectReason, "reject reason for \(c.name)") + XCTAssertEqual(metaLabel(parsed), c.meta, "historical-metadata classification for \(c.name)") + // The reason and the verdict are one statement, not two that could drift apart. + XCTAssertEqual(parsed.ok, parsed.rejectReason == .none, + "\(c.name): ok must hold exactly when the reason is none") + } + } + + /// Scenario "Der zweite verifizierende Pfad ist mit abgedeckt": the ECG payload accessor is the + /// other place a frame envelope is judged, so the oracle carries its answer too. + func testEveryEcgPathCaseMatchesTheSharedExpectation() throws { + let oracle = try loadOracle() + let ecgCases = oracle.cases.filter(\.ecgPath) + XCTAssertFalse(ecgCases.isEmpty, "the second verifying path must stay covered") + for c in ecgCases { + let payload = Whoop5Ecg.innerPayload(bytes(c.hex)) + XCTAssertEqual(payload.map(hexString), c.ecgInnerPayloadHex, + "ECG inner payload for \(c.name)") + } + } + + // MARK: - self-defence: the oracle cannot silently stop covering something + + func testOracleCoverageManifestMatchesTheCases() throws { + let oracle = try loadOracle() + XCTAssertEqual(oracle.cases.count, oracle.coverage.caseCount, "case_count") + XCTAssertEqual(Set(oracle.cases.map(\.name)).count, oracle.cases.count, "case names must be unique") + + var classes: [String: Int] = [:] + var reasons: [String: Int] = [:] + for c in oracle.cases { + classes[c.klass, default: 0] += 1 + reasons[c.rejectReason, default: 0] += 1 + } + XCTAssertEqual(classes, oracle.coverage.classes, "per-class counts") + XCTAssertEqual(reasons, oracle.coverage.reasons, "per-reason counts") + + // The input classes task 4.1 names. Losing one would leave the oracle green and blind. + for required in ["valid_recorded", "header_corrupt", "payload_corrupt", "below_minimum", + "truncated", "trailing_bytes", "boundary", "no_start_of_frame", + "evaluation_order", "ecg_path"] { + XCTAssertGreaterThan(classes[required] ?? 0, 0, "input class \(required) is not covered") + } + // Both families, on both sides of the verdict. + for fam in ["whoop4", "whoop5"] { + XCTAssertTrue(oracle.cases.contains { $0.family == fam && $0.verdict }, + "no accepted \(fam) frame in the oracle") + XCTAssertTrue(oracle.cases.contains { $0.family == fam && !$0.verdict }, + "no rejected \(fam) frame in the oracle") + } + // Every reachable reason. `payloadCRCUnverifiable` is unreachable on both platforms and is + // therefore absent by design — asserting it here would demand an impossible input. + for reason in FrameRejectReason.allCases where reason != .payloadCRCUnverifiable { + XCTAssertGreaterThan(reasons[reason.rawValue] ?? 0, 0, + "reason \(reason.rawValue) is not covered by any oracle case") + } + XCTAssertNil(reasons["payloadCRCUnverifiable"], + "payloadCRCUnverifiable cannot be produced by any input — a line claiming it is wrong") + // Each historical-metadata outcome, since that is the third pinned field. + let metas = Set(oracle.cases.map(\.meta)) + XCTAssertTrue(metas.contains("start")) + XCTAssertTrue(metas.contains("complete")) + XCTAssertTrue(metas.contains("other")) + XCTAssertTrue(metas.contains { $0.hasPrefix("end(") }) + } + + func testOracleCopiesAreIdentical() throws { + let swiftURL = try XCTUnwrap(Bundle.module.url(forResource: Self.oracleResource, withExtension: "json")) + let swiftData = try Data(contentsOf: swiftURL) + + // .../Packages/WhoopProtocol/Tests/WhoopProtocolTests/FrameIntegrityOracleTests.swift -> repo root + let repoRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // WhoopProtocolTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // WhoopProtocol + .deletingLastPathComponent() // Packages + .deletingLastPathComponent() // repo root + let androidURL = repoRoot.appendingPathComponent(Self.androidCopy) + guard FileManager.default.fileExists(atPath: androidURL.path) else { + throw XCTSkip("android oracle copy not present at \(androidURL.path)") + } + XCTAssertEqual(swiftData, try Data(contentsOf: androidURL), + "frame_integrity_oracle.json copies differ — keep the Swift and Android copies in lockstep") + } +} diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FrameIntegrityTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FrameIntegrityTests.swift new file mode 100644 index 0000000000..1558255f12 --- /dev/null +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FrameIntegrityTests.swift @@ -0,0 +1,402 @@ +import XCTest +@testable import WhoopProtocol + +/// The frame-integrity contract: the parse result carries the verifier's FULL verdict plus a +/// non-optional reason, the structural bounds are enforced per device family, and a named inner +/// field is only ever read out of payload bytes. +/// +/// Every synthetic frame below is built the way a strap builds one (real CRC-8 / CRC-16-Modbus +/// header checksum, real zlib CRC32 payload trailer, exact declared length) and then broken in +/// exactly ONE way, so each assertion attributes a single cause. The frames taken from the capture +/// corpus are marked as such. +final class FrameIntegrityTests: XCTestCase { + + static func hex(_ s: String) -> [UInt8] { FramingTests.hex(s) } + + // MARK: - Fixtures + + /// Real captured WHOOP 4.0 REALTIME_DATA frame (28 bytes), fully valid. + static let w4Valid = "aa1800ff28020f3de10128663c0000000000000000000000da855212" + /// Real captured WHOOP 4.0 METADATA frames. The 11-byte ones sit EXACTLY on the family minimum + /// and their `meta_type` occupies the last payload byte — the case that fixes the bound's + /// comparison form ("start + length must not exceed the limit"). + static let w4MetaHistoryStart11 = "aa07006b3100012366efad" + static let w4MetaHistoryComplete11 = "aa07006b3100030f07e143" + /// Real captured 25-byte HISTORY_END frame. Its CRC32 trailer starts at 21, and the 8-byte + /// acknowledgement block the strap echoes back runs from 17 to 25 — into the trailer. + static let w4HistoryEnd25 = "aa15001631000200f1536500000000000039300000c3401bdb" + /// Synthetic, fully valid WHOOP 5.0/MG COMMAND_RESPONSE frame (20 bytes). + static let w5Valid = "aa010c000001e74124070211223344557481f36e" + + /// Total 8, 9 and 10 bytes: declared lengths 4, 5 and 6. Each carries a correct header checksum + /// and a correct CRC32 over its (0-2 byte) inner record, so ONLY the structural minimum rejects + /// them — which is the point. + static let w4Total8 = "aa04005400000000" + static let w4Total9 = "aa05004131b7efdc83" + static let w4Total10 = "aa06007e31004d158487" + /// Total 12 bytes (declared 4): a WHOOP 5.0 frame one byte below the minimum, again with both + /// checksums correct. Its byte at offset 8 — the inner packet type — is the first byte of its + /// own CRC32 trailer. + static let w5Total12 = "aa0104000001e52100000000" + + /// Exactly 13 bytes (declared 5): valid header checksum, valid CRC32 over its single payload + /// byte, exact length. It PASSES the tightened gate; the sequence number, command byte and + /// metadata type it appears to carry are all inside its CRC32 trailer. + static let w5Min13Metadata = "aa0105000001e4dd31b7efdc83" + /// The same frame at 14 bytes (declared 6). It is fully valid AND the first byte of its trailer + /// reads 3 = HISTORY_COMPLETE at the metadata-type offset — the forgery F-05 describes. The + /// sequence number at offset 9 is now a genuine payload byte and must still decode. + static let w5Meta14FakesHistoryComplete = "aa0106000001e49931530315e675" + + /// WHOOP 4.0 REALTIME_DATA with a SHORTENED inner record: 14 bytes, declared length 10, so the + /// trailer starts at 10 and everything the schema places at 10 or beyond is trailer, not data. + static let w4ShortRealtime14 = "aa0a0082280111223344854deb31" + /// 11 bytes present, declared length 61440. Structurally the frame claims ~60 KB it does not + /// have; the bound must fall back on the bytes that exist. + static let w4HugeDeclared11 = "aa00f0de31000102030405" + + private func corrupt(_ hexString: String, at index: Int) -> [UInt8] { + var f = Self.hex(hexString) + f[index] ^= 0xFF + return f + } + + // MARK: - Vollständiges Integritätsurteil im Parse-Ergebnis + + func testWhoop4HeaderChecksumWrongPayloadCRCRightIsRejected() { + let frame = corrupt(Self.w4Valid, at: 3) + let parsed = parseFrame(frame) + XCTAssertFalse(parsed.ok, "a broken header checksum must not yield a positive verdict") + XCTAssertEqual(parsed.rejectReason, .headerChecksumMismatch) + // The payload CRC32 is untouched — this is exactly the class that passed the gates before. + XCTAssertEqual(parsed.crcOK, true) + // …and the frame stays readable for an inspector. + XCTAssertEqual(parsed.typeName, "REALTIME_DATA") + } + + func testWhoop5HeaderChecksumWrongPayloadCRCRightIsRejected() { + let frame = corrupt(Self.w5Valid, at: 6) + let parsed = parseFrame(frame, family: .whoop5) + XCTAssertFalse(parsed.ok) + XCTAssertEqual(parsed.rejectReason, .headerChecksumMismatch) + XCTAssertEqual(parsed.crcOK, true) + XCTAssertEqual(parsed.typeName, "COMMAND_RESPONSE") + } + + func testPayloadCRCThatCannotBeComputedIsNegativeNotUnknown() { + // Too short for a CRC32 over any payload byte at all. + let parsed = parseFrame(Self.hex(Self.w4Total8)) + XCTAssertFalse(parsed.ok, "an uncomputable payload CRC must never read as a pass") + XCTAssertNil(parsed.crcOK, "the tri-state stays honest: the CRC is unknown …") + XCTAssertEqual(parsed.rejectReason, .belowMinimumLength, "… but the verdict is not") + } + + func testFullyValidFrameKeepsItsPositiveVerdictAndFields() { + let parsed = parseFrame(Self.hex(Self.w4Valid)) + XCTAssertTrue(parsed.ok) + XCTAssertEqual(parsed.rejectReason, .none) + XCTAssertEqual(parsed.crcOK, true) + XCTAssertEqual(parsed.typeName, "REALTIME_DATA") + XCTAssertEqual(parsed.seq, 2) + XCTAssertEqual(parsed.parsed["heart_rate"], .int(60)) + + let five = parseFrame(Self.hex(Self.w5Valid), family: .whoop5) + XCTAssertTrue(five.ok) + XCTAssertEqual(five.rejectReason, .none) + XCTAssertEqual(five.seq, 7) + } + + // MARK: - Ablehnungsgrund liegt am Parse-Ergebnis an + + func testConsumerReadsTheReasonFromTheParseResultAlone() { + // A consumer sees only what it was handed — no frame bytes, no second verify, no second + // parse. That is the whole point of carrying the reason on the result. + func report(_ parsed: ParsedFrame) -> FrameRejectReason { parsed.rejectReason } + XCTAssertEqual(report(parseFrame(corrupt(Self.w4Valid, at: 3))), .headerChecksumMismatch) + XCTAssertEqual(report(parseFrame(corrupt(Self.w4Valid, at: 10))), .payloadCRCMismatch) + } + + func testEveryRejectionReasonIsDistinguishable() { + var wrongSOF = Self.hex(Self.w4Valid) + wrongSOF[0] = 0x00 + let trailing = Self.hex(Self.w4Valid) + [0x00] + + let reasons: [FrameRejectReason] = [ + parseFrame(wrongSOF).rejectReason, + parseFrame(Self.hex(Self.w4Total10)).rejectReason, + parseFrame(trailing).rejectReason, + parseFrame(corrupt(Self.w4Valid, at: 3)).rejectReason, + parseFrame(corrupt(Self.w4Valid, at: 10)).rejectReason, + parseFrame(Self.hex(Self.w4Valid)).rejectReason, + ] + XCTAssertEqual(reasons, [.noStartOfFrame, .belowMinimumLength, .lengthMismatch, + .headerChecksumMismatch, .payloadCRCMismatch, .none]) + XCTAssertEqual(Set(reasons).count, reasons.count, "the reasons must not collapse into each other") + // The seventh reason exists so an uncomputable CRC can never be reported as a wrong one. The + // structural rules above make it unreachable from the verifier today; it is a fail-closed + // default, and this pins that it is a value of its own rather than an alias. + XCTAssertFalse(reasons.contains(.payloadCRCUnverifiable)) + XCTAssertEqual(FrameRejectReason.allCases.count, 7) + } + + func testValidFrameCarriesNoReason() { + XCTAssertEqual(parseFrame(Self.hex(Self.w4Valid)).rejectReason, .none) + XCTAssertEqual(parseFrame(Self.hex(Self.w5Valid), family: .whoop5).rejectReason, .none) + XCTAssertEqual(verifyFrame(Self.hex(Self.w4Valid)).reason, .none) + } + + func testMissingReasonInAnOlderSerialisedResultDecodesAsNone() throws { + // Capture files and hand-written fixtures predate the field; decoding must not turn strict. + let json = Data(""" + {"ok":true,"typeName":"REALTIME_DATA","crcOK":true,"lenBytes":28,"rawHex":"", + "fields":[],"parsed":{}} + """.utf8) + let decoded = try JSONDecoder().decode(ParsedFrame.self, from: json) + XCTAssertEqual(decoded.rejectReason, .none) + } + + // MARK: - Strukturelle Mindest- und Genaulänge je Gerätefamilie + + func testWhoop4BelowMinimumLengthIsRejectedWithoutReadingAPacketType() { + for h in [Self.w4Total8, Self.w4Total9, Self.w4Total10] { + let frame = Self.hex(h) + let check = verifyFrame(frame) + XCTAssertFalse(check.ok, "\(h) is below the 11-byte minimum") + XCTAssertEqual(check.reason, .belowMinimumLength, "\(h)") + let parsed = parseFrame(frame, collectFields: true) + XCTAssertFalse(parsed.ok, "\(h)") + XCTAssertEqual(parsed.typeName, "INVALID/FRAGMENT", "no packet type is read out of \(h)") + XCTAssertTrue(parsed.fields.isEmpty, "\(h)") + XCTAssertTrue(parsed.parsed.isEmpty, "\(h)") + } + } + + func testWhoop5BelowMinimumLengthIsRejectedInsteadOfReadingTheTrailer() { + let frame = Self.hex(Self.w5Total12) + let check = verifyFrame(frame, family: .whoop5) + XCTAssertFalse(check.ok) + XCTAssertEqual(check.reason, .belowMinimumLength) + // Its header checksum is genuinely correct (recomputed here, since a frame this short is not + // read at all any more), so only the structural minimum stands between it and the gates — + // and its "inner packet type" would be the first byte of its own CRC32 trailer. + XCTAssertEqual(crc16Modbus(Array(frame[0..<6])), UInt16(frame[6]) | (UInt16(frame[7]) << 8)) + XCTAssertNil(check.crc8OK, "nothing is read out of a frame below the minimum") + let parsed = parseFrame(frame, family: .whoop5, collectFields: true) + XCTAssertEqual(parsed.typeName, "INVALID/FRAGMENT") + XCTAssertTrue(parsed.parsed.isEmpty) + } + + func testFramesExactlyAtEachFamilyMinimumStayValid() { + // 11 bytes, from the capture corpus — the smallest real WHOOP 4.0 frame there is. + let w4 = Self.hex(Self.w4MetaHistoryStart11) + XCTAssertEqual(w4.count, FrameLimits.whoop4MinimumFrameBytes) + XCTAssertTrue(verifyFrame(w4).ok) + // Its metadata type occupies the LAST payload byte; the bound must not swallow it, or the + // offload would never learn that a chunk ended. + XCTAssertEqual(parseFrame(w4).parsed["meta_type"], .string("HISTORY_START(1)")) + + let w5 = Self.hex(Self.w5Min13Metadata) + XCTAssertEqual(w5.count, FrameLimits.whoop5MinimumFrameBytes) + XCTAssertTrue(verifyFrame(w5, family: .whoop5).ok) + } + + func testOneByteTooManyIsRejected() { + for (h, family) in [(Self.w4Valid, DeviceFamily.whoop4), (Self.w5Valid, .whoop5)] { + let frame = Self.hex(h) + [0x00] + let check = verifyFrame(frame, family: family) + XCTAssertFalse(check.ok, "trailing bytes must be rejected (\(family))") + XCTAssertEqual(check.reason, .lengthMismatch, "\(family)") + // The payload CRC still verifies — the envelope is what is wrong, and the diagnostic + // must say so rather than blame the payload. + XCTAssertEqual(check.crc32OK, true, "\(family)") + XCTAssertFalse(parseFrame(frame, family: family).ok, "\(family)") + } + } + + func testOneByteTooFewIsRejected() { + for (h, family) in [(Self.w4Valid, DeviceFamily.whoop4), (Self.w5Valid, .whoop5)] { + let frame = Array(Self.hex(h).dropLast()) + let check = verifyFrame(frame, family: family) + XCTAssertFalse(check.ok, "a truncated frame must be rejected (\(family))") + XCTAssertEqual(check.reason, .lengthMismatch, "\(family)") + XCTAssertNil(check.crc32OK, "a CRC over bytes we do not have is not computed (\(family))") + XCTAssertFalse(parseFrame(frame, family: family).ok, "\(family)") + } + } + + // MARK: - Innere Felder werden nur aus Nutzdatenbytes gelesen + + func testWhoop5AtTheMinimumDecodesNoSequenceCommandOrMetadataField() { + let frame = Self.hex(Self.w5Min13Metadata) + let parsed = parseFrame(frame, family: .whoop5, collectFields: true) + // It passes the gate — which is exactly why the field bound has to hold on its own. + XCTAssertTrue(parsed.ok) + XCTAssertEqual(parsed.typeName, "METADATA") + XCTAssertNil(parsed.seq, "offset 9 is the first byte of the CRC32 trailer") + XCTAssertNil(parsed.parsed["meta_type"], "offset 10 is trailer, not a metadata type") + XCTAssertNil(parsed.parsed["cmd"]) + XCTAssertFalse(parsed.fields.contains { $0.name == "seq" }) + } + + func testWhoop5OneByteAboveTheMinimumDecodesTheSequenceButNotTheMetadataType() { + let frame = Self.hex(Self.w5Meta14FakesHistoryComplete) + XCTAssertEqual(frame.count, 14) + let parsed = parseFrame(frame, family: .whoop5, collectFields: true) + XCTAssertTrue(parsed.ok, "the frame is fully valid — nothing but the bound protects it") + // Offset 9 is now a genuine payload byte. + XCTAssertEqual(parsed.seq, 83) + // Offset 10 holds 0x03 — the byte that means HISTORY_COMPLETE — but it is the first byte of + // the CRC32 trailer, so no metadata type is decoded from it. + XCTAssertEqual(frame[10], 3) + XCTAssertNil(parsed.parsed["meta_type"], + "a frame must not be able to forge a chunk state out of its own checksum") + XCTAssertNil(parsed.parsed["cmd"]) + } + + func testWhoop4WithAShortenedInnerRecordStopsAtTheTrailer() { + let frame = Self.hex(Self.w4ShortRealtime14) + XCTAssertEqual(frame.count, 14) + let parsed = parseFrame(frame, collectFields: true) + XCTAssertTrue(parsed.ok, "valid header checksum, valid CRC32, exact length") + XCTAssertEqual(parsed.typeName, "REALTIME_DATA") + // The declared length is 10, so the trailer starts at 10. + XCTAssertEqual(parsed.parsed["timestamp"], .int(0x44332211), "offset 6..10 is payload") + XCTAssertNil(parsed.parsed["subseconds"], "offset 10 is trailer") + XCTAssertNil(parsed.parsed["heart_rate"], "offset 12 is trailer") + XCTAssertNil(parsed.parsed["rr_count"], "offset 13 is trailer") + } + + func testDeclaredLengthFarBeyondTheBytesReadsNothingThatIsNotThere() { + // 11 bytes present, ~60 KB declared. The bound is the MINIMUM of the trailer start and the + // real size, so it falls back on the 11 bytes we hold. Reaching for the declared trailer + // start instead would read off the end of the buffer. + let frame = Self.hex(Self.w4HugeDeclared11) + let check = verifyFrame(frame) + XCTAssertEqual(check.length, 61440) + XCTAssertFalse(check.ok) + XCTAssertEqual(check.reason, .lengthMismatch) + XCTAssertNil(check.crc32OK) + let parsed = parseFrame(frame, collectFields: true) + XCTAssertFalse(parsed.ok) + for field in parsed.fields { + XCTAssertLessThanOrEqual(field.off + field.len, frame.count, + "field \(field.name) reaches past the bytes we hold") + } + // The same shape below the family minimum — the usual on-the-wire case, a fragment whose + // length word promises thousands of bytes — decodes no field at all. + let fragment = Array(frame.dropLast()) + let fragmentParsed = parseFrame(fragment, collectFields: true) + XCTAssertTrue(fragmentParsed.fields.isEmpty) + XCTAssertTrue(fragmentParsed.parsed.isEmpty) + XCTAssertEqual(fragmentParsed.rejectReason, .belowMinimumLength) + } + + func testAcknowledgementBlockOfTheRealHistoryEndFrameIsUnchanged() { + // The 8-byte block the trim acknowledgement mirrors back to the strap is NOT a decoded + // field: it is an opaque echo, and by construction it reaches into the CRC32 trailer. The + // field bound must never be applied to it — a clipped block either makes the strap refuse + // the acknowledgement (the offload stops) or makes it trim on altered bytes. + let frame = Self.hex(Self.w4HistoryEnd25) + XCTAssertEqual(frame.count, 25) + let check = verifyFrame(frame) + XCTAssertTrue(check.ok) + XCTAssertEqual(check.length, 21, "the CRC32 trailer starts at 21 …") + // … and the block runs 17..25, four bytes of it inside that trailer. Derive the block from + // the frame rather than from a second literal: comparing one spelling of the fixture with + // another cannot fail, and this assertion exists to fail when a later tidy-up narrows the + // block. The literal pin stays as the byte-level record of what the strap is echoed. + let ackStart = 25 - 8 + let block = Array(frame.suffix(8)) + XCTAssertEqual(block, Array(frame[ackStart..<25])) + XCTAssertEqual(block, Self.hex("39300000c3401bdb")) + // The exception has to BE an exception: the block must straddle the D7 bound, or this test + // would be pinning an ordinary payload read and would go on passing after a clip. + let bound = check.length! + XCTAssertLessThan(ackStart, bound, "the block starts inside the payload …") + XCTAssertGreaterThan(25, bound, "… and ends inside the CRC32 trailer") + // What a clipped block would look like, spelled out so the difference is not a matter of + // reading: applying the field bound would hand the strap four bytes instead of eight. + XCTAssertNotEqual(block, Array(frame[ackStart.. Data { + let url = try XCTUnwrap(Bundle.module.url(forResource: name, withExtension: "json"), + "missing \(name).json test resource") + return try Data(contentsOf: url) + } + + /// Every real frame the package's capture corpus holds must keep a POSITIVE verdict under the + /// tightened rules — minimum length, exact length, header checksum and payload CRC32 together. + /// This is the regression guard for the whole change: the corpus is what a strap actually sent. + func testEveryRecordedFrameInTheCorpusStaysValid() throws { + var checked = 0 + func assertValid(_ hexString: String, _ family: DeviceFamily, _ label: String) { + let frame = Self.hex(hexString) + let check = verifyFrame(frame, family: family) + XCTAssertTrue(check.ok, "\(label) (\(frame.count) B) rejected: \(check.reason)") + XCTAssertEqual(check.reason, .none, "\(label)") + XCTAssertTrue(parseFrame(frame, family: family).ok, "\(label)") + checked += 1 + } + + let dec = JSONDecoder() + for (i, e) in try dec.decode([HexEntry].self, from: resource("frames")).enumerated() { + assertValid(e.hex, .whoop4, "frames.json #\(i)") + } + for (i, e) in try dec.decode([HexEntry].self, from: resource("historical_frames")).enumerated() { + assertValid(e.hex, .whoop4, "historical_frames.json #\(i)") + } + for (i, e) in try dec.decode(DecoderOracleFile.self, from: resource("decoder_oracle")).frames.enumerated() { + assertValid(e.hex, e.family == "whoop5" ? .whoop5 : .whoop4, "decoder_oracle.json #\(i)") + } + for (i, e) in try dec.decode(OpticalOracleFile.self, + from: resource("r20_optical_oracle")).records.enumerated() { + assertValid(e.hex, .whoop5, "r20_optical_oracle.json #\(i)") + } + XCTAssertEqual(checked, 116, "the corpus size is pinned so a lost resource cannot pass as green") + } +} diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FramingTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FramingTests.swift index f8c6707e8e..8e3804c741 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FramingTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FramingTests.swift @@ -69,7 +69,9 @@ final class FramingTests: XCTestCase { // inner = [43,0,0] + data (7 bytes); length = 7 + 4 = 11. XCTAssertEqual(frame[0], 0xAA) XCTAssertEqual(Int(frame[1]) | (Int(frame[2]) << 8), 11) - XCTAssertEqual(frame[3], 0x00) // placeholder crc8 + // The header checksum is computed for real (it used to be a 0x00 placeholder), so a rebuilt + // frame is one a strap could have sent rather than one every gate now rejects. + XCTAssertEqual(frame[3], crc8([frame[1], frame[2]])) XCTAssertEqual(frame[4], 43) XCTAssertEqual(frame[5], 0) XCTAssertEqual(frame[6], 0) @@ -80,8 +82,12 @@ final class FramingTests: XCTestCase { let got = UInt32(frame[11]) | (UInt32(frame[12]) << 8) | (UInt32(frame[13]) << 16) | (UInt32(frame[14]) << 24) XCTAssertEqual(got, want) - // The reconstructed frame's crc32 must verify (crc8 is a placeholder so .ok stays false). - XCTAssertEqual(verifyFrame(frame).crc32OK, true) + // The reconstructed frame verifies end to end: both checksums AND the exact length. + let check = verifyFrame(frame) + XCTAssertEqual(check.crc32OK, true) + XCTAssertEqual(check.crc8OK, true) + XCTAssertTrue(check.ok) + XCTAssertEqual(check.reason, .none) } func testFrameFromPayloadDefaults() { diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/HistoricalMetaTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/HistoricalMetaTests.swift index a57d7c1ab3..642537f836 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/HistoricalMetaTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/HistoricalMetaTests.swift @@ -107,4 +107,109 @@ final class HistoricalMetaTests: XCTestCase { let p = metaParsed(cmd: 3) XCTAssertEqual(p.parsed["meta_type"], .string("HISTORY_COMPLETE(3)")) } + + // MARK: - the gate: a metadata frame that is not INTACT classifies as nothing + // + // Scenario "Zustandstreibende Tore fordern das volle Urteil / Verlaufs-Metadaten können nicht + // gefälscht werden". Each frame below is a real, decodable HISTORY_END / HISTORY_COMPLETE broken in + // exactly ONE way, so what the classifier refuses is attributable. `.end`/`.complete` are the two + // results that advance the trim cursor and ack the strap to free its records, which is why they are + // the ones a forged frame must never reach. + + /// Header checksum wrong, payload CRC32 RIGHT — the class that passed every gate before this change. + func testHistoryEndWithABrokenHeaderChecksumIsNotClassified() { + let payload: [UInt8] = le32(1_700_000_000) + le16(0) + le32(0) + le32(4242) + var frame = frameFromPayload(payload, type: 49, seq: 0, cmd: 2) + frame[3] ^= 0xFF // CRC-8 over the length field only + let p = parseFrame(frame) + XCTAssertEqual(p.crcOK, true, "precondition: only the HEADER checksum is broken") + XCTAssertEqual(p.rejectReason, .headerChecksumMismatch) + XCTAssertEqual(p.typeName, "METADATA", "the frame stays readable for an inspector …") + XCTAssertEqual(p.parsed["meta_type"], .string("HISTORY_END(2)"), "… including its meta type") + XCTAssertEqual(classifyHistoricalMeta(p), .other, + "… but it must not advance the trim cursor or ack the strap") + } + + func testHistoryCompleteWithABrokenHeaderChecksumIsNotClassified() { + var frame = frameFromPayload([], type: 49, seq: 0, cmd: 3) + frame[3] ^= 0xFF + let p = parseFrame(frame) + XCTAssertEqual(p.parsed["meta_type"], .string("HISTORY_COMPLETE(3)")) + XCTAssertEqual(classifyHistoricalMeta(p), .other) + } + + /// Declared length below the WHOOP 4.0 minimum of 11 total bytes. The bytes are all still there — + /// only the length word claims a frame too small to hold an inner record. + func testHistoryEndWithADeclaredLengthBelowTheMinimumIsNotClassified() { + let payload: [UInt8] = le32(1_700_000_000) + le16(0) + le32(0) + le32(4242) + var frame = frameFromPayload(payload, type: 49, seq: 0, cmd: 2) + frame[1] = 6; frame[2] = 0 // declared 6 → total 10, below the 11-byte floor + frame[3] = crc8(frame, 1, 3) // …with a CORRECT header checksum for that word + let p = parseFrame(frame) + XCTAssertEqual(p.rejectReason, .belowMinimumLength) + XCTAssertEqual(classifyHistoricalMeta(p), .other) + } + + /// The CRC32 trailer is cut off: the declared length promises four bytes that are not there. + func testHistoryEndWithATruncatedTrailerIsNotClassified() { + let payload: [UInt8] = le32(1_700_000_000) + le16(0) + le32(0) + le32(4242) + let full = frameFromPayload(payload, type: 49, seq: 0, cmd: 2) + let cut = Array(full.dropLast(2)) // still >= 11 bytes, so it parses + let p = parseFrame(cut) + XCTAssertEqual(p.rejectReason, .lengthMismatch) + XCTAssertEqual(p.typeName, "METADATA") + XCTAssertEqual(p.parsed["meta_type"], .string("HISTORY_END(2)"), + "precondition: without the gate this frame WOULD be read as a history end") + XCTAssertEqual(classifyHistoricalMeta(p), .other) + } + + /// Trailing bytes past the frame's own end — the other half of the exact-length rule. + func testHistoryCompleteWithTrailingBytesIsNotClassified() { + let frame = frameFromPayload([], type: 49, seq: 0, cmd: 3) + [0x00, 0x00] + let p = parseFrame(frame) + XCTAssertEqual(p.rejectReason, .lengthMismatch) + XCTAssertEqual(classifyHistoricalMeta(p), .other) + } + + /// The counterpart that must NOT regress: the smallest REAL metadata frames in the capture corpus + /// sit exactly on the 11-byte minimum, with the meta type in their last payload byte. If the bound + /// or the gate were one byte stricter, the offload would never see a history end and never finish. + func testRealElevenByteMetadataFramesStillClassify() { + let start = FrameIntegrityTests.hex(FrameIntegrityTests.w4MetaHistoryStart11) + let complete = FrameIntegrityTests.hex(FrameIntegrityTests.w4MetaHistoryComplete11) + XCTAssertEqual(classifyHistoricalMeta(parseFrame(start)), .start) + XCTAssertEqual(classifyHistoricalMeta(parseFrame(complete)), .complete) + } + + /// And the real 25-byte HISTORY_END still yields its unix + trim cursor. + func testRealHistoryEndFrameStillClassifies() { + let p = parseFrame(FrameIntegrityTests.hex(FrameIntegrityTests.w4HistoryEnd25)) + guard case .end(let unix, let trim) = classifyHistoricalMeta(p) else { + return XCTFail("the real HISTORY_END frame must still classify as an end, got \(classifyHistoricalMeta(p))") + } + XCTAssertEqual(unix, 1_700_000_000) + XCTAssertEqual(trim, 12345) + } + + // MARK: - a metadata TYPE is never read out of the CRC32 trailer (D7, shared with package 1) + + /// Scenario "Kein Byte des CRC-Anhangs wird als Metadatentyp gelesen". This 14-byte WHOOP 5.0 frame + /// is fully INTACT — correct header checksum, correct CRC32, exact length — and the first byte of + /// its own trailer sits where the metadata type would be read, carrying the value 3 + /// (HISTORY_COMPLETE). Only the payload bound stops it, so this is the case that would end an + /// offload on a forged frame the integrity gate cannot object to. + func testWhoop5FrameAtTheMinimumCannotFakeHistoryComplete() { + let frame = FrameIntegrityTests.hex(FrameIntegrityTests.w5Meta14FakesHistoryComplete) + let p = parseFrame(frame, family: .whoop5) + XCTAssertTrue(p.ok, "precondition: the gate has no objection to this frame at all") + XCTAssertEqual(p.typeName, "METADATA") + XCTAssertNil(p.parsed["meta_type"], "the type byte would come from the CRC32 trailer") + XCTAssertEqual(classifyHistoricalMeta(p), .other) + } + + func testWhoop5FrameAtTheMinimumLengthClassifiesAsNothing() { + let p = parseFrame(FrameIntegrityTests.hex(FrameIntegrityTests.w5Min13Metadata), family: .whoop5) + XCTAssertTrue(p.ok, "precondition: 13 bytes is the family minimum, not a rejection") + XCTAssertEqual(classifyHistoricalMeta(p), .other) + } } diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/HistoricalV24Tests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/HistoricalV24Tests.swift index 32eef06afb..b87622e30b 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/HistoricalV24Tests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/HistoricalV24Tests.swift @@ -73,7 +73,12 @@ final class HistoricalV24Tests: XCTestCase { var bad = bytes(v24Hex) bad[5] = 99 // flip version byte to an unmapped value let out = parseFrame(bad) - XCTAssertTrue(out.ok) // parse is defensive (crc will mismatch) + // The flipped byte is inside the CRC32-covered inner record, so the frame is NOT intact and + // `ok` says so — it used to report a positive verdict for exactly this deliberately corrupted + // frame. Decoding stays defensive: the fields are still readable for an inspector. + XCTAssertFalse(out.ok) + XCTAssertEqual(out.rejectReason, .payloadCRCMismatch) + XCTAssertEqual(out.crcOK, false) XCTAssertEqual(out.parsed["hist_version"]?.intValue, 99) } } diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/ReToolsTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/ReToolsTests.swift index 0156e48ec2..cd29a06322 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/ReToolsTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/ReToolsTests.swift @@ -119,7 +119,29 @@ final class ReToolsTests: XCTestCase { XCTAssertTrue(g.unknownBytes.allSatisfy { $0.constant }) } - // MARK: - C) inventory: census sorted by count, ok/crc, ts span, len spread + // MARK: - 2.13: the hand-built parse result still decodes after the new field appeared + + // ReTools' fixtures build a ParsedFrame by decoding a JSON dict that has no `rejectReason` key at + // all — the same shape older capture files have. Decoding must stay TOLERANT and default it to + // `.none`, or every fixture in this file (and every archived capture) becomes undecodable. + func testHandBuiltParseResultDecodesWithoutARejectReason() { + let f = frame(type: "EVENT", len: 4) + XCTAssertEqual(f.rejectReason, .none, + "a document without the key must decode, defaulting the reason to none") + // And a document that DOES carry one keeps it, so the field is genuinely read and not ignored. + let dict: [String: Any] = [ + "ok": false, "typeName": "EVENT", "seq": NSNull(), "cmdName": NSNull(), + "crcOK": true, "lenBytes": 12, "rawHex": "", "fields": [], "parsed": [:], + "rejectReason": "headerChecksumMismatch", + ] + let data = try! JSONSerialization.data(withJSONObject: dict) + let decoded = try! JSONDecoder().decode(ParsedFrame.self, from: data) + XCTAssertEqual(decoded.rejectReason, .headerChecksumMismatch) + XCTAssertTrue(decoded.payloadCRCOKButEnvelopeRejected, + "payload CRC32 right + envelope wrong is the class that used to pass the gates") + } + + // MARK: - C) inventory: census sorted by count, intact/parsable/crc, ts span, len spread func testInventoryCensus() { let hist = frame(type: "HISTORICAL_DATA", seq: 26, len: 8, ok: true, crcOK: true) @@ -133,8 +155,11 @@ final class ReToolsTests: XCTestCase { XCTAssertEqual(inv.first?.key, "HISTORICAL_DATA/v26") // 3 records → most common, sorts first let h = inv.first! XCTAssertEqual(h.count, 3) - XCTAssertEqual(h.okCount, 2) + XCTAssertEqual(h.intactCount, 2) XCTAssertEqual(h.crcOkCount, 2) + // All three carry a decoded packet type, including the one that is not intact: parseability and + // integrity are separate columns, and the census must not report a readable frame as unreadable. + XCTAssertEqual(h.parsableCount, 3) XCTAssertEqual(h.firstTsMs, 1000) XCTAssertEqual(h.lastTsMs, 3000) XCTAssertEqual(h.minLen, 6) diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/RejectedHistoryTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/RejectedHistoryTests.swift index cb6023ea9b..5d50d38de0 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/RejectedHistoryTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/RejectedHistoryTests.swift @@ -77,6 +77,31 @@ final class RejectedHistoryTests: XCTestCase { XCTAssertTrue(rejectedHistoricalRecords([v26], family: .whoop5).isEmpty) } + /// The v26 skip is bound to the VERDICT, not to the version byte alone. Its whole premise is that + /// `extractHistoricalStreams` stores such a record durably in the PPG waveform stream — which stops + /// being true the moment the record is rejected: the extraction drops it, and an unconditional skip + /// here would leave it archived nowhere while the section is acked anyway. + func testWhoop5V26RecordWithABrokenHeaderChecksumIsArchived() { + var bad = bytes(whoop5V26Hex) + bad[6] ^= 0xFF // CRC-16-Modbus over the first six bytes + XCTAssertEqual(bad[8], 47) // still a HISTORICAL_DATA record… + XCTAssertEqual(bad[9], 26) // …still version 26 + let p = parseFrame(bad, family: .whoop5) + XCTAssertEqual(p.crcOK, true, "precondition: the PAYLOAD CRC32 still verifies") + XCTAssertEqual(p.rejectReason, .headerChecksumMismatch) + XCTAssertEqual(rejectedHistoricalRecords([bad], family: .whoop5), [bad], + "a rejected v26 record reaches no stream, so its bytes are the only copy left") + } + + /// The other direction of the same rule: binding the skip to the verdict must not start archiving + /// the NORMAL case. An intact v26 record is stored in its own stream and stays out of the archive. + func testIntactWhoop5V26RecordIsStillNotArchived() { + let v26 = bytes(whoop5V26Hex) + XCTAssertTrue(parseFrame(v26, family: .whoop5).ok, "precondition: the record is intact") + XCTAssertTrue(rejectedHistoricalRecords([v26], family: .whoop5).isEmpty, + "the archive must not grow by the normal case") + } + func testNonHistoricalFrameExcluded() { // A REALTIME_DATA (type-40) frame is live, not offload — never a history-loss candidate. let realtime = frameFromPayload([0x01, 0x02, 0x03], type: 40, seq: 0, cmd: 0) @@ -98,6 +123,75 @@ final class RejectedHistoryTests: XCTestCase { XCTAssertEqual(rejected, [bad]) } + // MARK: - D8: this reader runs the OTHER WAY ROUND — a negative verdict means ARCHIVE + + /// Scenario "Beweissichernde Leser verlieren keine Rahmen / Rahmen mit kaputtem Header wird + /// gesichert statt verworfen". Before the change this frame passed as decodable and was archived + /// NOWHERE; the strap frees it on the next trim ack, so the archive is the only copy that can exist. + func testWhoop4RecordWithABrokenHeaderChecksumIsArchived() { + var bad = bytes(v24Hex) + bad[3] ^= 0xFF // CRC-8 over the length field only + let p = parseFrame(bad) + XCTAssertEqual(p.crcOK, true, "precondition: the PAYLOAD CRC32 still verifies") + XCTAssertEqual(p.rejectReason, .headerChecksumMismatch) + XCTAssertEqual(rejectedHistoricalRecords([bad], family: .whoop4), [bad], + "a frame we cannot trust is a frame we must keep the bytes of") + } + + func testWhoop5RecordWithABrokenHeaderChecksumIsArchived() { + var bad = bytes(whoop5V18Hex) + bad[6] ^= 0xFF // CRC-16-Modbus over the first six bytes + XCTAssertEqual(bad[8], 47) + XCTAssertEqual(parseFrame(bad, family: .whoop5).rejectReason, .headerChecksumMismatch) + XCTAssertEqual(rejectedHistoricalRecords([bad], family: .whoop5), [bad]) + } + + func testWhoop4RecordWithTrailingBytesIsArchived() { + let bad = bytes(v24Hex) + [0x00] // one byte past the frame's own end + XCTAssertEqual(parseFrame(bad).rejectReason, .lengthMismatch) + XCTAssertEqual(rejectedHistoricalRecords([bad], family: .whoop4), [bad]) + } + + func testWhoop4RecordWithATruncatedTrailerIsArchived() { + let bad = Array(bytes(v24Hex).dropLast(2)) + XCTAssertEqual(parseFrame(bad).rejectReason, .lengthMismatch) + XCTAssertEqual(rejectedHistoricalRecords([bad], family: .whoop4), [bad]) + } + + /// Scenario "Bisher gesicherte Rahmen bleiben gesichert", over the real capture corpus. + /// + /// The pre-change reader is re-implemented here verbatim, with the one substitution the change + /// makes: its `ok` was a constant that meant "these bytes read as a frame", which is + /// `isParsable` today. Every frame it would have archived must still be archived. The direction + /// is asserted as a SUBSET, not as equality, because the new reader is expected to archive more. + func testNoCorpusFrameIsArchivedLessThanBefore() throws { + let url = try XCTUnwrap(Bundle.module.url(forResource: "frames", withExtension: "json"), + "the WHOOP 4.0 capture corpus must be present") + struct HexOnly: Decodable { let hex: String } + let corpus = try JSONDecoder().decode([HexOnly].self, from: Data(contentsOf: url)) + .map { bytes($0.hex) } + XCTAssertGreaterThan(corpus.count, 50, "precondition: a corpus worth calling a corpus") + + // The pre-change predicate, for WHOOP 4.0. + func archivedBefore(_ f: [UInt8]) -> Bool { + guard f.count > 4, Int(f[4]) == 47 else { return false } + let p = parseFrame(f, family: .whoop4) + if !p.isParsable || p.crcOK == false { return true } + return p.parsed["unix"]?.intValue == nil + || (p.parsed["heart_rate"]?.intValue == nil && p.parsed["gravity_x"]?.doubleValue == nil) + } + + let before = corpus.filter(archivedBefore) + let now = Set(rejectedHistoricalRecords(corpus, family: .whoop4).map { Data($0) }) + for f in before { + XCTAssertTrue(now.contains(Data(f)), + "a frame archived before this change must still be archived: \(f.prefix(8))") + } + // And nothing INTACT and decodable was dragged in: the corpus's clean records stay out. + let clean = corpus.filter { $0.count > 4 && Int($0[4]) == 47 && parseFrame($0).ok } + XCTAssertGreaterThan(clean.count, 0, "precondition: the corpus holds intact type-47 records") + } + func testIsEmptyRecordFrameFlagsAllZeroPayloadOnly() { // A 104 B frame with header + CRC bytes set but the record payload (21.. empty. var empty = [UInt8](repeating: 0, count: 104) diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Resources/frame_integrity_oracle.json b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Resources/frame_integrity_oracle.json new file mode 100644 index 0000000000..541873e8f4 --- /dev/null +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Resources/frame_integrity_oracle.json @@ -0,0 +1,498 @@ +{ + "_comment": [ + "SHARED FRAME-INTEGRITY ORACLE (change frame-integrity, D5 / task 4.2).", + "Generated — do not hand-edit. Regenerate with:", + "swift run --package-path openspec/changes/frame-integrity/oracle-gen FrameIntegrityOracleGen", + "Two byte-identical copies are committed, one per test tree, and both suites assert every case", + "and all three pinned fields (verdict, reject_reason, meta), so neither platform can move its", + "verifier without the other's suite going red.", + "verdict = ParsedFrame.ok, the verifier's FULL verdict.", + "reject_reason = the Swift FrameRejectReason rawValue; Kotlin maps its enum through wireName.", + "meta = classifyHistoricalMeta, as start | complete | other | end(unix=U,trim=T).", + "ecg_inner_payload_hex is present only on cases with ecg_path true (the second verifying path,", + "Whoop5Ecg.innerPayload); null means that path refused the frame.", + "The export FORM of a reject reason differs by design (Kotlin omits the key when there is no", + "reason, Swift always writes it) — this oracle pins the verdict, the reason and the", + "classification, never the export spelling." + ], + "coverage": { + "case_count": 45, + "classes": { + "below_minimum": 4, + "boundary": 3, + "ecg_path": 3, + "evaluation_order": 4, + "header_corrupt": 5, + "no_start_of_frame": 4, + "payload_corrupt": 5, + "trailing_bytes": 3, + "truncated": 4, + "valid_recorded": 9, + "valid_synthetic": 1 + }, + "reasons": { + "belowMinimumLength": 5, + "headerChecksumMismatch": 7, + "lengthMismatch": 10, + "noStartOfFrame": 4, + "none": 14, + "payloadCRCMismatch": 5 + } + }, + "cases": [ + { + "name": "w4_realtime_valid", + "family": "whoop4", + "class": "valid_recorded", + "hex": "aa1800ff28020f3de10128663c0000000000000000000000da855212", + "verdict": true, + "reject_reason": "none", + "meta": "other", + "ecg_path": false + }, + { + "name": "w4_meta_history_start_11", + "family": "whoop4", + "class": "valid_recorded", + "hex": "aa07006b3100012366efad", + "verdict": true, + "reject_reason": "none", + "meta": "start", + "ecg_path": false + }, + { + "name": "w4_meta_history_complete_11", + "family": "whoop4", + "class": "valid_recorded", + "hex": "aa07006b3100030f07e143", + "verdict": true, + "reject_reason": "none", + "meta": "complete", + "ecg_path": false + }, + { + "name": "w4_history_end_25", + "family": "whoop4", + "class": "valid_recorded", + "hex": "aa15001631000200f1536500000000000039300000c3401bdb", + "verdict": true, + "reject_reason": "none", + "meta": "end(unix=1700000000,trim=12345)", + "ecg_path": false + }, + { + "name": "w4_historical_v24_valid", + "family": "whoop4", + "class": "valid_recorded", + "hex": "aa6400a12f18054c1c0a023ed0266a5037805418016d022b0234020000000000006b07ff0085593c1f65cebed7b3e63eb85a5f3f000080401f65cebed7b3e63eb85a5f3f500264025d03640229014009010c020c00000000000f0001c4020000000000008fdeb278", + "verdict": true, + "reject_reason": "none", + "meta": "other", + "ecg_path": false + }, + { + "name": "w4_historical_v25_valid", + "family": "whoop4", + "class": "valid_recorded", + "hex": "aa50000c2f1900006800007dff2a6a20430900433103007e026502ba026c022eff70f996f879fad6fd8300d6017e0267027201be00290258030e05c507f00c030ead11cb15791500d2553c9003000000d6393716", + "verdict": true, + "reject_reason": "none", + "meta": "other", + "ecg_path": false + }, + { + "name": "w5_historical_v18_valid", + "family": "whoop5", + "class": "valid_recorded", + "hex": "aa01740001003fb12f1280733d8401b69f266a66460066025a0265020000000000007b0a8d656463ff0012163cf6a439bf2924fd3ed763fe3e3200aa000000000000000000f7000901f10b0007010c020c00000000000000000000000000000000000000000000000100656f1e1e0000009d61a7c00000003e862817", + "verdict": true, + "reject_reason": "none", + "meta": "other", + "ecg_path": false + }, + { + "name": "w5_meta_history_end_valid", + "family": "whoop5", + "class": "valid_recorded", + "hex": "aa011c00010023d1316a0284a3266a0a373d00000041b601001000000000000044d21e3d", + "verdict": true, + "reject_reason": "none", + "meta": "end(unix=1780917124,trim=112193)", + "ecg_path": false + }, + { + "name": "w5_meta_history_start_valid", + "family": "whoop5", + "class": "valid_recorded", + "hex": "aa012c0001002cd1312c0184a3266ad7230d0000005200000000000000320000002600000001000000000000000b010034497926", + "verdict": true, + "reject_reason": "none", + "meta": "start", + "ecg_path": false + }, + { + "name": "w5_command_response_valid", + "family": "whoop5", + "class": "valid_synthetic", + "hex": "aa010c000001e74124070211223344557481f36e", + "verdict": true, + "reject_reason": "none", + "meta": "other", + "ecg_path": false + }, + { + "name": "w4_realtime_header_checksum_corrupt", + "family": "whoop4", + "class": "header_corrupt", + "hex": "aa18000028020f3de10128663c0000000000000000000000da855212", + "verdict": false, + "reject_reason": "headerChecksumMismatch", + "meta": "other", + "ecg_path": false + }, + { + "name": "w4_history_end_25_header_corrupt", + "family": "whoop4", + "class": "header_corrupt", + "hex": "aa1500e931000200f1536500000000000039300000c3401bdb", + "verdict": false, + "reject_reason": "headerChecksumMismatch", + "meta": "other", + "ecg_path": false + }, + { + "name": "w4_meta_complete_11_header_corrupt", + "family": "whoop4", + "class": "header_corrupt", + "hex": "aa0700943100030f07e143", + "verdict": false, + "reject_reason": "headerChecksumMismatch", + "meta": "other", + "ecg_path": false + }, + { + "name": "w5_command_response_header_crc16_corrupt", + "family": "whoop5", + "class": "header_corrupt", + "hex": "aa010c000001184124070211223344557481f36e", + "verdict": false, + "reject_reason": "headerChecksumMismatch", + "meta": "other", + "ecg_path": false + }, + { + "name": "w5_meta_history_end_header_corrupt", + "family": "whoop5", + "class": "header_corrupt", + "hex": "aa011c000100dcd1316a0284a3266a0a373d00000041b601001000000000000044d21e3d", + "verdict": false, + "reject_reason": "headerChecksumMismatch", + "meta": "other", + "ecg_path": false + }, + { + "name": "w4_realtime_payload_corrupt", + "family": "whoop4", + "class": "payload_corrupt", + "hex": "aa1800ff28020f3d1e0128663c0000000000000000000000da855212", + "verdict": false, + "reject_reason": "payloadCRCMismatch", + "meta": "other", + "ecg_path": false + }, + { + "name": "w4_history_end_25_payload_corrupt", + "family": "whoop4", + "class": "payload_corrupt", + "hex": "aa150016310002000e536500000000000039300000c3401bdb", + "verdict": false, + "reject_reason": "payloadCRCMismatch", + "meta": "other", + "ecg_path": false + }, + { + "name": "w4_historical_v24_payload_corrupt", + "family": "whoop4", + "class": "payload_corrupt", + "hex": "aa6400a12f18054c1c0a023ed0266a5037805418fe6d022b0234020000000000006b07ff0085593c1f65cebed7b3e63eb85a5f3f000080401f65cebed7b3e63eb85a5f3f500264025d03640229014009010c020c00000000000f0001c4020000000000008fdeb278", + "verdict": false, + "reject_reason": "payloadCRCMismatch", + "meta": "other", + "ecg_path": false + }, + { + "name": "w5_historical_v18_payload_corrupt", + "family": "whoop5", + "class": "payload_corrupt", + "hex": "aa01740001003fb12f1280733d8401b69f266a66b90066025a0265020000000000007b0a8d656463ff0012163cf6a439bf2924fd3ed763fe3e3200aa000000000000000000f7000901f10b0007010c020c00000000000000000000000000000000000000000000000100656f1e1e0000009d61a7c00000003e862817", + "verdict": false, + "reject_reason": "payloadCRCMismatch", + "meta": "other", + "ecg_path": false + }, + { + "name": "w5_meta_history_end_payload_corrupt", + "family": "whoop5", + "class": "payload_corrupt", + "hex": "aa011c00010023d1316a0284a3266a0a373d0000ff41b601001000000000000044d21e3d", + "verdict": false, + "reject_reason": "payloadCRCMismatch", + "meta": "other", + "ecg_path": false + }, + { + "name": "w4_total8_declared4", + "family": "whoop4", + "class": "below_minimum", + "hex": "aa04005400000000", + "verdict": false, + "reject_reason": "belowMinimumLength", + "meta": "other", + "ecg_path": false + }, + { + "name": "w4_total9_declared5", + "family": "whoop4", + "class": "below_minimum", + "hex": "aa05004131b7efdc83", + "verdict": false, + "reject_reason": "belowMinimumLength", + "meta": "other", + "ecg_path": false + }, + { + "name": "w4_total10_declared6", + "family": "whoop4", + "class": "below_minimum", + "hex": "aa06007e31004d158487", + "verdict": false, + "reject_reason": "belowMinimumLength", + "meta": "other", + "ecg_path": false + }, + { + "name": "w5_total12_declared4", + "family": "whoop5", + "class": "below_minimum", + "hex": "aa0104000001e52100000000", + "verdict": false, + "reject_reason": "belowMinimumLength", + "meta": "other", + "ecg_path": true, + "ecg_inner_payload_hex": null + }, + { + "name": "w4_realtime_truncated_by_1", + "family": "whoop4", + "class": "truncated", + "hex": "aa1800ff28020f3de10128663c0000000000000000000000da8552", + "verdict": false, + "reject_reason": "lengthMismatch", + "meta": "other", + "ecg_path": false + }, + { + "name": "w4_history_end_25_trailer_truncated", + "family": "whoop4", + "class": "truncated", + "hex": "aa15001631000200f1536500000000000039300000c340", + "verdict": false, + "reject_reason": "lengthMismatch", + "meta": "other", + "ecg_path": false + }, + { + "name": "w5_historical_v18_truncated_by_1", + "family": "whoop5", + "class": "truncated", + "hex": "aa01740001003fb12f1280733d8401b69f266a66460066025a0265020000000000007b0a8d656463ff0012163cf6a439bf2924fd3ed763fe3e3200aa000000000000000000f7000901f10b0007010c020c00000000000000000000000000000000000000000000000100656f1e1e0000009d61a7c00000003e8628", + "verdict": false, + "reject_reason": "lengthMismatch", + "meta": "other", + "ecg_path": false + }, + { + "name": "w4_huge_declared_length_11_present", + "family": "whoop4", + "class": "truncated", + "hex": "aa00f0de31000102030405", + "verdict": false, + "reject_reason": "lengthMismatch", + "meta": "other", + "ecg_path": false + }, + { + "name": "w4_realtime_trailing_byte", + "family": "whoop4", + "class": "trailing_bytes", + "hex": "aa1800ff28020f3de10128663c0000000000000000000000da85521200", + "verdict": false, + "reject_reason": "lengthMismatch", + "meta": "other", + "ecg_path": false + }, + { + "name": "w4_meta_complete_11_trailing_byte", + "family": "whoop4", + "class": "trailing_bytes", + "hex": "aa07006b3100030f07e14300", + "verdict": false, + "reject_reason": "lengthMismatch", + "meta": "other", + "ecg_path": false + }, + { + "name": "w5_command_response_trailing_byte", + "family": "whoop5", + "class": "trailing_bytes", + "hex": "aa010c000001e74124070211223344557481f36e00", + "verdict": false, + "reject_reason": "lengthMismatch", + "meta": "other", + "ecg_path": false + }, + { + "name": "w5_min13_metadata_boundary", + "family": "whoop5", + "class": "boundary", + "hex": "aa0105000001e4dd31b7efdc83", + "verdict": true, + "reject_reason": "none", + "meta": "other", + "ecg_path": false + }, + { + "name": "w5_meta14_fakes_history_complete", + "family": "whoop5", + "class": "boundary", + "hex": "aa0106000001e49931530315e675", + "verdict": true, + "reject_reason": "none", + "meta": "other", + "ecg_path": false + }, + { + "name": "w4_short_realtime_14", + "family": "whoop4", + "class": "boundary", + "hex": "aa0a0082280111223344854deb31", + "verdict": true, + "reject_reason": "none", + "meta": "other", + "ecg_path": false + }, + { + "name": "no_start_of_frame_w4", + "family": "whoop4", + "class": "no_start_of_frame", + "hex": "bb1800ff28020f3de10128663c0000000000000000000000da855212", + "verdict": false, + "reject_reason": "noStartOfFrame", + "meta": "other", + "ecg_path": false + }, + { + "name": "no_start_of_frame_w5", + "family": "whoop5", + "class": "no_start_of_frame", + "hex": "bb010c000001e74124070211223344557481f36e", + "verdict": false, + "reject_reason": "noStartOfFrame", + "meta": "other", + "ecg_path": false + }, + { + "name": "empty_byte_run_w4", + "family": "whoop4", + "class": "no_start_of_frame", + "hex": "", + "verdict": false, + "reject_reason": "noStartOfFrame", + "meta": "other", + "ecg_path": false + }, + { + "name": "empty_byte_run_w5", + "family": "whoop5", + "class": "no_start_of_frame", + "hex": "", + "verdict": false, + "reject_reason": "noStartOfFrame", + "meta": "other", + "ecg_path": false + }, + { + "name": "w4_length_field_corrupt_order_length_before_header", + "family": "whoop4", + "class": "evaluation_order", + "hex": "aae700ff28020f3de10128663c0000000000000000000000da855212", + "verdict": false, + "reject_reason": "lengthMismatch", + "meta": "other", + "ecg_path": false + }, + { + "name": "w4_header_and_payload_both_corrupt", + "family": "whoop4", + "class": "evaluation_order", + "hex": "aa18000028020f3d1e0128663c0000000000000000000000da855212", + "verdict": false, + "reject_reason": "headerChecksumMismatch", + "meta": "other", + "ecg_path": false + }, + { + "name": "w4_below_minimum_and_header_corrupt", + "family": "whoop4", + "class": "evaluation_order", + "hex": "aa0400ab00000000", + "verdict": false, + "reject_reason": "belowMinimumLength", + "meta": "other", + "ecg_path": false + }, + { + "name": "w5_trailing_byte_and_header_corrupt", + "family": "whoop5", + "class": "evaluation_order", + "hex": "aa010c000001184124070211223344557481f36e00", + "verdict": false, + "reject_reason": "lengthMismatch", + "meta": "other", + "ecg_path": false + }, + { + "name": "w5_ecg_filtered_valid", + "family": "whoop5", + "class": "ecg_path", + "hex": "aa0120000001efd128010002050101000100012a003d3f2c031103000a00f6ff2c0100004decffd3", + "verdict": true, + "reject_reason": "none", + "meta": "other", + "ecg_path": true, + "ecg_inner_payload_hex": "02050101000100012a003d3f2c031103000a00f6ff2c010000" + }, + { + "name": "w5_ecg_filtered_trailing_byte", + "family": "whoop5", + "class": "ecg_path", + "hex": "aa0120000001efd128010002050101000100012a003d3f2c031103000a00f6ff2c0100004decffd300", + "verdict": false, + "reject_reason": "lengthMismatch", + "meta": "other", + "ecg_path": true, + "ecg_inner_payload_hex": null + }, + { + "name": "w5_ecg_header_crc16_corrupt", + "family": "whoop5", + "class": "ecg_path", + "hex": "aa012000000110d128010002050101000100012a003d3f2c031103000a00f6ff2c0100004decffd3", + "verdict": false, + "reject_reason": "headerChecksumMismatch", + "meta": "other", + "ecg_path": true, + "ecg_inner_payload_hex": null + } + ] +} diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/ScanAdvertisementSummaryTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/ScanAdvertisementSummaryTests.swift new file mode 100644 index 0000000000..02c635ddac --- /dev/null +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/ScanAdvertisementSummaryTests.swift @@ -0,0 +1,106 @@ +import Foundation +import XCTest +@testable import WhoopProtocol + +/// The advertisement summary (#1635) — the twin of the Kotlin `ScanAdvertisementSummaryTest`, same +/// cases and same expected text so the two platforms cannot describe a strap differently. +/// +/// Its job is to make two advertising modes distinguishable in a strap log without carrying anything +/// that identifies a person or a device. WHOOP names a strap "'s Whoop" by default, so the local +/// name is the one field that must never appear. +final class ScanAdvertisementSummaryTests: XCTestCase { + + private func line(flags: Int? = 0x06, + svc: [String] = ["61080001-8d6d-82b8-614a-1c8cb0f8dcc6"], + svcData: [String: Int] = [:], + mfg: [Int: Int] = [:], + tx: Int? = nil, + nameLen: Int? = 12, + connectable: Bool = true) -> String { + ScanAdvertisementSummary.line(flags: flags, serviceUuids: svc, serviceDataLengths: svcData, + manufacturerDataLengths: mfg, txPower: tx, + localNameLength: nameLen, connectable: connectable) + } + + /// The headline guarantee: shape is reported, payload never is. + func testSummaryCarriesNoPayloadBytesAndNoName() { + let s = line(svcData: ["fd4b": 9], mfg: [0x01D9: 14], nameLen: 13) + XCTAssertTrue(s.contains("0000fd4b-0000-1000-8000-00805f9b34fb:9B")) + XCTAssertTrue(s.contains("0x01d9:14B")) + XCTAssertTrue(s.contains("nameLen=13")) + XCTAssertFalse(s.contains("Whoop")) + XCTAssertFalse(s.contains("'s")) + } + + /// The point of the line: two advertising modes must produce different text, or the #1635 question + /// stays unanswerable. + func testDifferentAdvertisingModeReadsDifferently() { + let normal = line(flags: 0x06, svcData: [:]) + let pairing = line(flags: 0x05, svcData: ["fd4b": 4]) + XCTAssertNotEqual(normal, pairing) + XCTAssertTrue(normal.contains("flags=0x06")) + XCTAssertTrue(pairing.contains("flags=0x05")) + XCTAssertTrue(normal.contains("svcData=none")) + XCTAssertTrue(pairing.contains("0000fd4b-0000-1000-8000-00805f9b34fb:4B")) + } + + /// Absent fields say so rather than vanishing, so two logs stay comparable field by field. + func testAbsentFieldsAreNamedNotOmitted() { + let s = line(flags: nil, svc: [], tx: nil, nameLen: nil) + XCTAssertTrue(s.contains("flags=none")) + XCTAssertTrue(s.contains("svc=none")) + XCTAssertTrue(s.contains("tx=none")) + XCTAssertTrue(s.contains("nameLen=none")) + } + + /// Deterministic ordering, so two captures diff cleanly instead of by dictionary order. + func testOutputIsStableRegardlessOfInputOrder() { + let a = ScanAdvertisementSummary.line(flags: 6, serviceUuids: ["b", "a"], + serviceDataLengths: ["y": 1, "x": 2], + manufacturerDataLengths: [2: 1, 1: 2], + txPower: nil, localNameLength: 4, connectable: true) + let b = ScanAdvertisementSummary.line(flags: 6, serviceUuids: ["a", "b"], + serviceDataLengths: ["x": 2, "y": 1], + manufacturerDataLengths: [1: 2, 2: 1], + txPower: nil, localNameLength: 4, connectable: true) + XCTAssertEqual(a, b) + } + + /// Connectability separates a pairing-ready strap from a beacon-only one. + func testConnectabilityIsReported() { + XCTAssertTrue(line(connectable: true).contains("connectable=true")) + XCTAssertTrue(line(connectable: false).contains("connectable=false")) + } + + /// The cross-platform guarantee. CoreBluetooth reports an assigned 16-bit UUID as "180d" while + /// Android always expands it, so without canonicalisation the SAME strap would log two different + /// lines and an iOS capture could not be diffed against an Android one. Both spellings must collapse + /// to one string — this is the assertion the Kotlin twin makes verbatim. + func testShortAndLongUuidSpellingsProduceTheSameLine() { + let short = line(svc: ["180d"], svcData: ["fd4b": 4]) + let long = line(svc: ["0000180d-0000-1000-8000-00805f9b34fb"], + svcData: ["0000fd4b-0000-1000-8000-00805f9b34fb": 4]) + XCTAssertEqual(short, long) + XCTAssertTrue(short.contains("svc=0000180d-0000-1000-8000-00805f9b34fb")) + } + + /// A 32-bit assigned UUID takes the same base, and a 128-bit one is passed through untouched. + func testCanonicalisationCoversThirtyTwoBitAndLeavesFullUuidsAlone() { + XCTAssertEqual(ScanAdvertisementSummary.canonicalUuid("0000180d"), + "0000180d-0000-1000-8000-00805f9b34fb") + let full = "61080001-8d6d-82b8-614a-1c8cb0f8dcc6" + XCTAssertEqual(ScanAdvertisementSummary.canonicalUuid(full), full) + } + + /// The other cross-platform trap in this line, and the reason it is measured in bytes. + /// + /// Swift's `String.count` would say 7 for this name and Java's `String.length` would say 8, so the + /// same strap logged a different size on each platform. The Kotlin twin asserts the SAME 10. + func testLocalNameLengthIsUtf8BytesNotCharacters() { + XCTAssertEqual(ScanAdvertisementSummary.localNameLength("Whoop \u{1F389}"), 10) + XCTAssertEqual("Whoop \u{1F389}".count, 7, "guards the premise: characters would disagree") + XCTAssertEqual(ScanAdvertisementSummary.localNameLength("Ryan's Whoop"), 12) + XCTAssertNil(ScanAdvertisementSummary.localNameLength(nil)) + } + +} diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/StandardHRContactTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/StandardHRContactTests.swift new file mode 100644 index 0000000000..d73a64f99a --- /dev/null +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/StandardHRContactTests.swift @@ -0,0 +1,22 @@ +import XCTest +@testable import WhoopProtocol + +/// The BLE Heart Rate Measurement flags decode must live on the protocol type so Swift and Kotlin +/// stay aligned: bit 2 = supported, bit 1 = detected, unsupported whenever bit 2 is clear. +final class StandardHRContactTests: XCTestCase { + func testFromMeasurementFlagsCoversAllCombinations() { + let cases: [(flags: UInt8, expected: StandardHRContact)] = [ + (0x00, .unsupported), + (0x02, .unsupported), + (0x04, .supportedNotDetected), + (0x06, .supportedDetected), + ] + for test in cases { + XCTAssertEqual( + StandardHRContact.fromMeasurementFlags(test.flags), + test.expected, + "flags 0x\(String(test.flags, radix: 16))" + ) + } + } +} diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/StreamsTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/StreamsTests.swift index 588069002b..97d932921c 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/StreamsTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/StreamsTests.swift @@ -73,4 +73,58 @@ final class StreamsTests: XCTestCase { deviceClockRef: deviceClockRef, wallClockRef: wallClockRef) XCTAssertEqual(s.hr.count, 1) } + + // MARK: - the extraction gate reads the FULL verdict + // + // Scenario "Zustandstreibende Tore fordern das volle Urteil / Strom- und Verlaufs-Extraktion + // überspringt ungültige Rahmen". Each frame is a real REALTIME_DATA broken in exactly one way and + // still fully decodable, so what is missing from the result is attributable to the gate. + + func testFrameWithABrokenHeaderChecksumYieldsNoRow() { + var broken = bytes(rt0) + broken[3] ^= 0xFF // header checksum only + let p = parseFrame(broken) + XCTAssertEqual(p.crcOK, true, "precondition: the payload CRC32 still verifies") + XCTAssertNotNil(p.parsed["heart_rate"], "precondition: the frame still decodes an HR") + let s = extractStreams([p], deviceClockRef: deviceClockRef, wallClockRef: wallClockRef) + XCTAssertTrue(s.hr.isEmpty, "no row may be derived from a frame that is not intact") + XCTAssertTrue(s.rr.isEmpty) + } + + func testFrameWithTrailingBytesYieldsNoRow() { + let p = parseFrame(bytes(rt0) + [0x00]) + XCTAssertEqual(p.rejectReason, .lengthMismatch) + let s = extractStreams([p], deviceClockRef: deviceClockRef, wallClockRef: wallClockRef) + XCTAssertTrue(s.hr.isEmpty) + } + + func testOneBrokenFrameInASequenceLosesOnlyItsOwnRow() { + var broken = bytes(rt1) + broken[3] ^= 0xFF + let s = extractStreams([parseFrame(bytes(rt0)), parseFrame(broken)], + deviceClockRef: deviceClockRef, wallClockRef: wallClockRef) + XCTAssertEqual(s.hr, [HRSample(ts: 1_736_365_593, bpm: 60)], + "the intact frame still produces its row; only the broken one is skipped") + } + + // MARK: - D7: a named field is never read out of the CRC32 trailer (post-hook half, 2.18) + + /// This 17-byte WHOOP 4.0 REALTIME_DATA frame is INTACT — correct header checksum, correct CRC32, + /// exact declared length of 13 — but its inner record ends at byte 13, so the R-R count the + /// realtime post-hook reads at offset 13 is the first byte of the frame's own CRC32 trailer. That + /// byte is 0x01, and the two trailer bytes behind it read as a 33403 ms interval: a physiologically + /// impossible R-R that the strap never sent and that nothing downstream could tell from a real one. + /// + /// The gate cannot catch this — the frame passes every checksum. Only the payload bound does. + func testRealtimeRRCountIsNotReadOutOfTheTrailer() { + let frame = bytes("aa0d00e9280100000009000000017b826e") + let p = parseFrame(frame) + XCTAssertTrue(p.ok, "precondition: this frame is intact by every checksum") + XCTAssertEqual(p.typeName, "REALTIME_DATA") + XCTAssertEqual(frame[13], 0x01, "precondition: the trailer's first byte reads as rr_count = 1") + XCTAssertEqual(p.parsed["rr_intervals"], .intArray([]), + "an R-R interval must never be decoded out of the CRC32 trailer") + let s = extractStreams([p], deviceClockRef: deviceClockRef, wallClockRef: wallClockRef) + XCTAssertTrue(s.rr.isEmpty, "…and no such row may reach the datastore") + } } diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop4HelloSerialTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop4HelloSerialTests.swift new file mode 100644 index 0000000000..7122ab0fad --- /dev/null +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop4HelloSerialTests.swift @@ -0,0 +1,92 @@ +import Foundation +import XCTest +@testable import WhoopProtocol + +/// The 4.0 serial decode (#1193) — twin of the Kotlin `Whoop4HelloSerialTest`, same cases, same values. +final class Whoop4HelloSerialTests: XCTestCase { + + /// A 131-byte cmd-35 response shaped like the captured one: a 9-char serial at 14, and the 54-char + /// DEVICE KEY at 24 that must stay unreachable. + private func capturedShape(serial: String = "WBB5AP053", + key: String = String(repeating: "K", count: 54)) -> [UInt8] { + var p = [UInt8](repeating: 0, count: 131) + for (i, c) in serial.utf8.enumerated() { p[14 + i] = c } + for (i, c) in key.utf8.enumerated() { p[24 + i] = c } + return p + } + + func testReadsTheSerialFromTheCapturedShape() { + XCTAssertEqual(Whoop4HelloSerial.decode(payload: capturedShape()), "WBB5AP053") + } + + /// The headline safety property: the decoder reads a fixed 9-byte window, so the device key beside + /// the serial cannot be returned no matter what it contains. + func testNeverReturnsTheDeviceKey() { + let out = Whoop4HelloSerial.decode(payload: capturedShape()) + XCTAssertEqual(out?.count, 9) + XCTAssertFalse(out?.contains("K") ?? true) + } + + /// A window that is not a clean alnum run is refused rather than guessed at — a moved field must + /// cost a strap its stable id, never migrate its history onto junk. + func testRefusesANonAlphanumericWindow() { + var p = capturedShape() + p[18] = 0x00 + XCTAssertNil(Whoop4HelloSerial.decode(payload: p)) + p = capturedShape() + p[14] = 0x2D // '-' is legal in an id but is NOT alnum, so this window is not a serial run + XCTAssertNil(Whoop4HelloSerial.decode(payload: p)) + } + + func testRefusesAShortPayload() { + XCTAssertNil(Whoop4HelloSerial.decode(payload: [UInt8](repeating: 0x41, count: 22))) + XCTAssertNil(Whoop4HelloSerial.decode(payload: [])) + } + + /// Exactly long enough is enough — the window ends at 23, so a 23-byte payload decodes. + func testAcceptsTheMinimumLength() { + var p = [UInt8](repeating: 0x41, count: 23) + for (i, c) in "ABC123XYZ".utf8.enumerated() { p[14 + i] = c } + XCTAssertEqual(Whoop4HelloSerial.decode(payload: p), "ABC123XYZ") + } +} + +/// The two-sighting gate (#1193) — twin of the Kotlin `RepeatedSerialGateTest`. +final class RepeatedSerialGateTests: XCTestCase { + + /// The point of the gate: one sighting is never enough to act on. + func testWithholdsUntilTheSameValueRepeats() { + var g = RepeatedSerialGate() + XCTAssertNil(g.offer("WBB5AP053")) + XCTAssertEqual(g.offer("WBB5AP053"), "WBB5AP053") + } + + /// The failure this exists to prevent: a per-session value never confirms, so it can never migrate + /// a history onto a fresh id per connect. + func testAValueThatKeepsChangingNeverConfirms() { + var g = RepeatedSerialGate() + for s in ["AAA111AAA", "BBB222BBB", "CCC333CCC", "DDD444DDD"] { + XCTAssertNil(g.offer(s), "a changing value must never confirm") + } + } + + /// Confirms once, not on every later sighting — the caller adopts on a non-nil return, and adopting + /// repeatedly would redo the migration on every connect. + func testConfirmsExactlyOnce() { + var g = RepeatedSerialGate() + _ = g.offer("WBB5AP053") + XCTAssertEqual(g.offer("WBB5AP053"), "WBB5AP053") + XCTAssertNil(g.offer("WBB5AP053")) + XCTAssertNil(g.offer("WBB5AP053")) + } + + /// An interruption resets the run: two sightings must be consecutive, so alternating values cannot + /// accumulate their way to a false confirmation. + func testAlternatingValuesDoNotConfirm() { + var g = RepeatedSerialGate() + XCTAssertNil(g.offer("AAA111AAA")) + XCTAssertNil(g.offer("BBB222BBB")) + XCTAssertNil(g.offer("AAA111AAA")) + XCTAssertEqual(g.offer("AAA111AAA"), "AAA111AAA") + } +} diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5EcgRawRecordTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5EcgRawRecordTests.swift new file mode 100644 index 0000000000..55ff493be8 --- /dev/null +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5EcgRawRecordTests.swift @@ -0,0 +1,82 @@ +import XCTest +@testable import WhoopProtocol + +/// #891/#1100: the type-43 REALTIME_RAW_DATA sample decoder, Swift side. +/// +/// Twin of the Kotlin `Whoop5EcgRawRecordTest`. The offsets are a protocol fact, so both platforms carry the +/// decode and both pin it — the parity contract is that these two cannot drift, and a test only on the side +/// that happens to call it today would not stop Swift drifting. +final class Whoop5EcgRawRecordTests: XCTestCase { + + /// A 240-byte record with `samples` written i16-LE from the waveform offset. + private func rawRecord(samples: [Int] = [], type: UInt8 = Whoop5Ecg.rawRecordType) -> [UInt8] { + var f = [UInt8](repeating: 0, count: Whoop5Ecg.rawRecordLength) + f[Whoop5Ecg.rawTypeOffset] = type + var i = Whoop5Ecg.rawWaveformStart + for v in samples { + if i + 1 >= Whoop5Ecg.rawBodyEnd { break } + let u = UInt16(bitPattern: Int16(truncatingIfNeeded: v)) + f[i] = UInt8(u & 0xFF) + f[i + 1] = UInt8((u >> 8) & 0xFF) + i += 2 + } + return f + } + + func testTheRecordCarriesExactly101Samples() { + // Load-bearing beyond arithmetic: this is the lag the BPM estimator has to refuse. + XCTAssertEqual(Whoop5Ecg.samplesPerRawRecord, 101) + XCTAssertEqual(Whoop5Ecg.realtimeRawSamples(rawRecord())?.count, 101) + } + + func testOnlyA240ByteType43FrameIsARawRecord() { + XCTAssertTrue(Whoop5Ecg.isRealtimeRawRecord(rawRecord())) + XCTAssertFalse(Whoop5Ecg.isRealtimeRawRecord(rawRecord(type: 0x2F))) + XCTAssertFalse(Whoop5Ecg.isRealtimeRawRecord(Array(rawRecord().prefix(200)))) + XCTAssertNil(Whoop5Ecg.realtimeRawSamples(rawRecord(type: 0x2F))) + XCTAssertNil(Whoop5Ecg.realtimeRawBodyNonZeroBytes([0, 0, 0, 0])) + XCTAssertNil(Whoop5Ecg.realtimeRawSignalPresent([])) + } + + func testSamplesAreSignedLittleEndian() { + let injected = [0, 1, -1, 32767, -32768, 258, -258] + let got = Whoop5Ecg.realtimeRawSamples(rawRecord(samples: injected))! + XCTAssertEqual(Array(got.prefix(injected.count)), injected) + // Everything past what was written stays zero rather than being trimmed away. + XCTAssertEqual(got[injected.count], 0) + } + + func testZeroSamplesAreKeptNotTrimmed() { + let got = Whoop5Ecg.realtimeRawSamples(rawRecord(samples: [50, 0, 0, 0]))! + XCTAssertEqual(got.count, 101) + XCTAssertEqual(got[0], 50) + XCTAssertTrue(got.dropFirst().allSatisfy { $0 == 0 }) + } + + func testSignalPresentIsOneRuleSharedByEveryConsumer() { + XCTAssertEqual(Whoop5Ecg.realtimeRawBodyNonZeroBytes(rawRecord()), 0) + XCTAssertEqual(Whoop5Ecg.realtimeRawSignalPresent(rawRecord()), false) + + // Just AT the threshold is still not "present" — the rule is strictly greater. + var atThreshold = rawRecord() + for i in 0..> 24) & 0xFF) return out } + + // MARK: - direct caller of the verifier (2.5): the useful path is unchanged + + /// `Whoop5RawOptical.decode` calls `verifyFrame` directly. The real captured record must still + /// decode under the tightened verifier — the 2,140-byte buffer declares 2,132, so its total is + /// exact and it clears the structural rules — and the reason on the check must be `none`, not just + /// a positive verdict by accident. + func testTheRealRecordStillDecodesUnderTheTightenedVerifier() throws { + let oracle = try loadOracle() + let bytes = hexToBytes(oracle.records[0].hex) + let check = verifyFrame(bytes, family: .whoop5) + XCTAssertTrue(check.ok) + XCTAssertEqual(check.reason, .none) + XCTAssertNotNil(Whoop5RawOptical.decode(bytes), "the useful path must be untouched") + } + + /// And the frame class the verifier newly rejects is refused before any sample is read. + func testARecordWithABrokenHeaderChecksumIsRefused() { + var frame = Self.sealedSyntheticFrame() + XCTAssertNotNil(Whoop5RawOptical.decode(frame), "control: the sealed frame decodes") + frame[6] ^= 0xFF // header CRC16 only; the payload CRC32 stands + XCTAssertEqual(verifyFrame(frame, family: .whoop5).crc32OK, true) + XCTAssertEqual(verifyFrame(frame, family: .whoop5).reason, .headerChecksumMismatch) + XCTAssertNil(Whoop5RawOptical.decode(frame)) + } } diff --git a/Packages/WhoopStore/Sources/WhoopStore/BackupSettings.swift b/Packages/WhoopStore/Sources/WhoopStore/BackupSettings.swift index e71786af04..e4af279b5a 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/BackupSettings.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/BackupSettings.swift @@ -34,9 +34,8 @@ public enum BackupSettings { /// (platform-neutral) names. Mirrored exactly by Android's `BackupSettingsCodec.WHITELIST`. /// /// Profile: the body metrics that power HR zones / calories / recovery baselines, plus the manual - /// HR-max override (`profile.hrMax`, 0 = auto/Tanaka). Display: the metric/imperial system, the - /// separate temperature override ("" = match the system), and the Effort axis (#268) — the three - /// display prefs that exist with identical semantics on both platforms. Deliberately EXCLUDED: + /// HR-max override (`profile.hrMax`, 0 = auto/Tanaka). Display: the body and exercise-distance + /// systems, separate temperature override ("" = follow body), and Effort axis (#268). Deliberately EXCLUDED: /// step calibration (per-strap, not per-person), the avatar blob (bulky, and not "settings"), /// steps-engine fitted outputs (derived), and every noop.* toggle that is device- or /// install-specific — INCLUDING the Today/Sleep section order, Key-Metrics and dashboard-card @@ -56,8 +55,10 @@ public enum BackupSettings { "profile.hrMax": .int, "profile.hrZoneThresholds": .string, "units.system": .string, + "units.distance": .string, "units.temperature": .string, "effort.scale": .string, + "dayCycle.mode": .string, "today.hostedCards": .string, // #1361: the user's own custom journal BEHAVIOURS (newline-joined names). Deliberately NOT in // `appleDefaultsKey` below — it isn't a flat UserDefaults key (customs are derived from the @@ -80,8 +81,10 @@ public enum BackupSettings { "profile.hrMax": "profile.hrMaxOverride", "profile.hrZoneThresholds": "profile.hrZoneThresholds", "units.system": "units.system", + "units.distance": "units.distance", "units.temperature": "units.temperature", "effort.scale": "effort.scale", + "dayCycle.mode": "noop.dayCycleMode", "today.hostedCards": "today.hostedCards", ] @@ -177,6 +180,11 @@ public enum BackupSettings { } private static func isBoolean(_ n: NSNumber) -> Bool { +#if canImport(Darwin) CFGetTypeID(n) == CFBooleanGetTypeID() +#else + // corelibs Foundation boxes JSON booleans with the Objective-C `c` type encoding. + String(cString: n.objCType) == "c" +#endif } } diff --git a/Packages/WhoopStore/Sources/WhoopStore/ClockRef.swift b/Packages/WhoopStore/Sources/WhoopStore/ClockRef.swift new file mode 100644 index 0000000000..4553bb7a10 --- /dev/null +++ b/Packages/WhoopStore/Sources/WhoopStore/ClockRef.swift @@ -0,0 +1,18 @@ +import Foundation + +/// The strap-clock ↔ wall-clock correlation captured at the start of a session. +/// +/// Declared here rather than beside the raw outbox because it has nothing to do with it. This is the +/// type the LIVE and BACKFILL paths carry — `Collector`, `Backfiller`, `ClockCorrelation` and +/// `BLEManager` all hold one, and every historical decode is offset by it. It reached the raw outbox +/// only because `RawBatchMeta` stores the same pair alongside a batch. +/// +/// It previously lived inside `RawOutbox.swift`, which is wrapped whole in `#if canImport(Compression)` +/// so the zlib-backed outbox compiles only on Apple platforms. That guard took this type with it, so on +/// a platform without Compression the entire clock-correlation vocabulary vanished from the module's +/// public API — a file-level guard reaching well past the surface it was meant to gate. +public struct ClockRef: Equatable, Codable { + public let device: Int + public let wall: Int + public init(device: Int, wall: Int) { self.device = device; self.wall = wall } +} diff --git a/Packages/WhoopStore/Sources/WhoopStore/CoachMessageStore.swift b/Packages/WhoopStore/Sources/WhoopStore/CoachMessageStore.swift new file mode 100644 index 0000000000..f057566eaa --- /dev/null +++ b/Packages/WhoopStore/Sources/WhoopStore/CoachMessageStore.swift @@ -0,0 +1,73 @@ +import Foundation +import GRDB + +// MARK: - v39 store: persisted Coach conversation (PRD-K2) +// +// CoachMessageStore.swift — GRDB CRUD over the `coachMessage` table (migration v39), so the AI +// Coach's chat survives relaunch. Mirrors the established `LabMarkerStore` idiom: a plain Codable +// row struct, raw `Row` fetch + manual decode, all GRDB work via the actor's `syncWrite`/`syncRead`. +// +// Stores only the coach's text replies + the user's questions — no raw biometric readings were ever +// in the chat (only the derived summary text sent with the request), so this carries the same +// no-raw-egress posture as the network call itself. NOT part of the `.noopbak` backup whitelist. + +/// One persisted turn in the Coach conversation. +public struct CoachMessageRow: Equatable, Codable, Sendable { + public var id: String + public var role: String // "user" | "assistant" + public var text: String + public var provider: String + public var createdAt: Int // epoch seconds + public var orderIndex: Int // monotonic replay order (two streamed turns can share createdAt) + + public init(id: String, role: String, text: String, provider: String, createdAt: Int, orderIndex: Int) { + self.id = id + self.role = role + self.text = text + self.provider = provider + self.createdAt = createdAt + self.orderIndex = orderIndex + } + + static func decode(_ row: Row) -> CoachMessageRow { + CoachMessageRow( + id: row["id"], role: row["role"], text: row["text"], provider: row["provider"], + createdAt: row["createdAt"], orderIndex: row["orderIndex"] + ) + } +} + +extension WhoopStore { + + /// The full stored conversation, oldest first (by `orderIndex`). + public func coachMessages() async throws -> [CoachMessageRow] { + try syncRead { db in + try Row.fetchAll(db, sql: "SELECT * FROM coachMessage ORDER BY orderIndex ASC") + .map(CoachMessageRow.decode) + } + } + + /// Replace the ENTIRE stored conversation with `rows` in one transaction. Simpler and safer than + /// incremental insert/update/delete bookkeeping across the engine's several streaming-finalization + /// call sites (a streamed reply mutates the same message's text repeatedly before settling); the + /// table is capped at the caller's `maxStoredMessages`, so a full replace is always cheap. Called + /// once per completed send/brief, never per streamed chunk. + public func replaceCoachMessages(_ rows: [CoachMessageRow]) async throws { + try syncWrite { db in + try db.execute(sql: "DELETE FROM coachMessage") + for row in rows { + try db.execute(sql: """ + INSERT INTO coachMessage (id, role, text, provider, createdAt, orderIndex) + VALUES (?, ?, ?, ?, ?, ?) + """, arguments: [row.id, row.role, row.text, row.provider, row.createdAt, row.orderIndex]) + } + } + } + + /// Wipe the entire stored conversation (the Coach toolbar's "Clear conversation" action). + public func clearCoachMessages() async throws { + try syncWrite { db in + try db.execute(sql: "DELETE FROM coachMessage") + } + } +} diff --git a/Packages/WhoopStore/Sources/WhoopStore/Database.swift b/Packages/WhoopStore/Sources/WhoopStore/Database.swift index 88f8ead082..21a049c2d6 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/Database.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/Database.swift @@ -538,12 +538,20 @@ extension WhoopStore { // That keeps a v26-heavy night to roughly the same order of magnitude as ONE extra per-second // stream (≈50 bytes/row), not 24x that. Additive only, a NEW table, no existing row touched. // - // Retention: no pruning, matching every other durable per-second table (hrSample, spo2Sample, … - // are never pruned either) — this is decoded biometric history, not the transient raw outbox. - // `PrunePolicy`'s ~50 MB cap governs ONLY `rawBatch` (raw, pre-decode frames kept for re-decode / - // re-sync); it is untouched by and unrelated to this table. Growth here is bounded by how much - // v26 data a strap actually emits (firmware chooses v26 vs v18 per second, not every night is - // v26-heavy), not by an artificial cap. + // Retention: CAPPED at `WhoopStore.ppgWaveformRetentionRows` newest rows per device (#1911), swept + // amortised on insert exactly like `v18AuxSample`. This table is the ONE exception to "no durable + // per-second table is pruned" (hrSample, spo2Sample, … are still never pruned), because it is the + // only one storing a blob rather than a scalar: ~120 B/row against ~30 B. It is the worst ROW, not + // the biggest table — v26 runs only in optical windows (~28,800 rows/day) where `rrInterval` banks + // ~100,000/day, so most of #1911's ~93 MB/day is still unbounded elsewhere. `PrunePolicy`'s + // ~50 MB cap governs ONLY `rawBatch` (raw, pre-decode frames kept for re-decode / re-sync); it is + // untouched by and unrelated to this table. + // + // The cap is NEWEST-N-ROWS, not an age-based drop, and that distinction is load-bearing for the + // consumer note below: a sporadic wearer's v26 seconds are spread thin over months, so deleting by + // wall-clock age would empty the table for exactly the user a future estimator needs most, while + // newest-N always leaves a full working set. See `ppgWaveformRetentionRows` for the byte maths and + // for why #1911's "diagnostic-only, drop after the hot window" framing was not followed. // // CONSUMER STATUS — deliberately none, and stated here so nobody has to re-derive it. The writer is // live on both platforms (offload + archive replay + the Android capture importer), but the reader @@ -572,16 +580,8 @@ extension WhoopStore { // the six wire columns (ax…az,gx…gz). Twin of the Android `rawImuSample` table (MIGRATION_20_21); // same column order + PK so a `.noopbak` round-trips byte-for-byte. // - // CONSUMER STATUS — deliberately none on this platform, in the same shape as the `ppgWaveformSample` - // (v27) and `v18AuxSample` (v31) notes. The writer (`Collector.storeRawImu`) is instrument-first: it - // fires only with raw capture enabled AND a 5/MG deep-data unlock, and is bounded to - // `WhoopStore.rawImuRetentionRows` (3600 one-second buffers, a rolling window — not a corpus). No - // analytic, score, gate, UI or export reads a row: the only SQL is the retention DELETE (`StreamStore`) - // plus a COUNT in the storage-stats readout (`LocalAccessCore`); `ImuFeatureExtractor` takes the - // protocol struct, never this table, so it is not a consumer either. Swift has NO reader yet, on - // purpose — a reader lands WITH a validated consumer, not before ("artifact, not one match", CLAUDE.md). - // Android keeps a dormant `rawImuSamples` reader (zero callers) for the eventual cross-check; do NOT - // "clean up" either side as dead code — the retained rows are the deliverable. Audit: #978. + // Historical rolling cache. Its opt-in writer retained at most 3,600 one-second rows, but no analytics, + // UI or export consumed them. v41 retires those legacy rows after file-backed capture replaces the cache. migrator.registerMigration("v28-raw-imu") { db in try db.create(table: "rawImuSample") { t in t.column("deviceId", .text).notNull() @@ -887,6 +887,38 @@ extension WhoopStore { t.add(column: "skinTempC", .double) } } + // Retire the bounded, write-only legacy cache. Session-owned IMU now lives in the file-backed store. + migrator.registerMigration("v41-drop-raw-imu-sample") { db in + try db.drop(table: "rawImuSample") + } + // Whether every sleep session that day was staged from heart rate alone (#1801). The Kotlin twin + // is `DailyMetric.sleepHrOnly`, added by Room MIGRATION_35_36; the shared schema oracle pins the + // two shapes together, so this exists here even while only Android reads it — a column present on + // one side and absent on the other is the drift #775 tracks, and stagingSparse set the precedent + // for carrying a staging-quality flag on both. + migrator.registerMigration("v42-daily-sleep-hr-only") { db in + try db.alter(table: "dailyMetric") { t in + t.add(column: "sleepHrOnly", .boolean) + } + } + // PRD-K2: persist the Coach conversation on-device so it survives relaunch. One row per chat + // turn; `orderIndex` is a monotonically-increasing counter (not `createdAt`, which two + // streamed turns can share to the second) so replay order is exact. `provider` isn't filtered + // on for v1 (a conversation is a conversation across a provider switch) but is carried so a + // future per-provider view/filter doesn't need another migration. Never added to the + // `.noopbak` backup whitelist (a separate, deliberate decision — CLAUDE.md's backup contract). + migrator.registerMigration("v43-coach-messages") { db in + try db.create(table: "coachMessage", options: [.ifNotExists]) { t in + t.column("id", .text).primaryKey() + t.column("role", .text).notNull() // "user" | "assistant" + t.column("text", .text).notNull() + t.column("provider", .text).notNull() + t.column("createdAt", .integer).notNull() + t.column("orderIndex", .integer).notNull() + } + // No index: the table is capped at maxStoredMessages (40 rows), so a full scan + sort on + // read is negligible and an index buys nothing worth the extra Room<->GRDB parity surface. + } return migrator } } diff --git a/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift b/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift index 7788dc3642..1c38d097a3 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift @@ -108,6 +108,11 @@ public struct DeviceRegistryStore: Sendable { } } + /// Suffix of the COMPUTED sibling every device id owns — the engine writes its derived days, detected + /// workouts and metric series under `-noop`. Spelled here so the adoption can carry it with + /// the pairing; the Kotlin twin is `WhoopRepository.computedDeviceId`. + public static let computedSuffix = "-noop" + /// Every table whose rows are keyed by `deviceId` (the per-device sample/derived tables). This is /// the authoritative list `deleteAllData` clears — kept in sync with the `deviceId`-keyed tables in /// `Database.swift`. The `pairedDevice` registry row itself is NOT here (a delete-data operation @@ -134,7 +139,6 @@ public struct DeviceRegistryStore: Sendable { "ppgWaveformSample", // v28-raw-imu (#423): the opt-in 5/MG raw-IMU offload capture is deviceId-keyed too — "delete all // of this device's data" must clear it, or the raw inertial samples survive deletion (same defect). - "rawImuSample", // v31-deep-capture-channels: the banked 5/MG v18 auxiliary fields are deviceId-keyed per-second // rows like every stream above, so a "delete all of this device's data" must clear them too. "v18AuxSample", @@ -199,9 +203,19 @@ public struct DeviceRegistryStore: Sendable { """, arguments: [serialId, activeId]) } // Move the active id's recordings onto the serial id (canonical wins a PK clash), then clear it. - for table in Self.deviceScopedTables { - try db.execute(sql: "UPDATE OR IGNORE \(table) SET deviceId = ? WHERE deviceId = ?", arguments: [serialId, activeId]) - try db.execute(sql: "DELETE FROM \(table) WHERE deviceId = ?", arguments: [activeId]) + // + // BOTH id shapes, not just the pairing's own. Every strap also owns a COMPUTED sibling keyed + // `-noop` — the scored days, detected workouts and metric series the engine derives + // — and that id is never equal to `activeId`, so an exact-match re-key silently left it behind. + // The next scoring pass then writes under `-noop`, stranding the old computed + // history under an id nothing reads again: the orphaned-history failure this adoption exists + // to PREVENT, displaced onto the computed half. The ring path never hit it because a ring has + // no computed sibling to strand, which is why the shipped migration looked proven. + for (from, to) in [(activeId, serialId), (activeId + Self.computedSuffix, serialId + Self.computedSuffix)] { + for table in Self.deviceScopedTables { + try db.execute(sql: "UPDATE OR IGNORE \(table) SET deviceId = ? WHERE deviceId = ?", arguments: [to, from]) + try db.execute(sql: "DELETE FROM \(table) WHERE deviceId = ?", arguments: [from]) + } } // Drop ONLY the provisional CB-UUID registry rows; other oura-* pairings are left as-is. try db.execute(sql: "DELETE FROM pairedDevice WHERE id = ?", arguments: [activeId]) diff --git a/Packages/WhoopStore/Sources/WhoopStore/HypnogramCoverage.swift b/Packages/WhoopStore/Sources/WhoopStore/HypnogramCoverage.swift new file mode 100644 index 0000000000..d955bf361e --- /dev/null +++ b/Packages/WhoopStore/Sources/WhoopStore/HypnogramCoverage.swift @@ -0,0 +1,153 @@ +import Foundation + +/// How much of a sleep session's `[startTs, endTs)` span its stage segments actually account for. +/// +/// WHY THIS EXISTS. A session's stage timeline is supposed to TILE its span — `SleepStageTotals` +/// says so explicitly ("the segment stages noop stores ... TILE the window ... Σ stage minutes equals +/// the clock span"), and every consumer is written as though it does. One producer breaks that +/// invariant: a device-PROVIDED hypnogram assembled from records that arrived INCOMPLETE. The Oura +/// path (`OuraSleepSessionMapping`) merges only CONTIGUOUS codes, so a sleep-phase page that never +/// arrived leaves a hole in `stagesJSON` while `startTs`/`endTs` still span the whole night. The +/// result is a session that looks well-formed — non-empty, many segments, a plausible efficiency — +/// but describes a fraction of the night it claims. +/// +/// Measured on 31 consecutive ring nights, 8 of them came in under 95% and one covered 23% of its +/// own span (601 min claimed, 140 min of segments). Downstream that night was stored as 70 minutes of +/// sleep against a paired strap's 494, and nothing flagged it: the merge's richness rule tests only +/// that stages are PRESENT, and Rest's two confidence guards are `gravitySparse` (inert here — Oura +/// stores no gravity at all, so `isGravitySparse` returns false for every ring night) and the #H9 +/// restorative floor (needs efficiency ≥ 0.85; the holed night read 0.50). +/// +/// So the missing quantity is not a new measurement — it is a RATIO of two numbers already stored. +/// Nothing here is persisted and no migration is needed: coverage is derived on read, which also means +/// it applies retroactively to nights already in the database. +/// +/// HONEST-DATA: this only ever reports how much of the night was OBSERVED. It never fills a hole in, +/// and in particular it must not let a caller treat unobserved time as awake — we do not know what +/// happened there, and asserting wake would be the same overreach in the opposite direction. +/// +/// PARITY: pure + deterministic, byte-identical to the Kotlin twin +/// (`com.noop.analytics.HypnogramCoverage`). Keep the two in lockstep. +public enum HypnogramCoverage { + + /// Coverage at or above which a stage timeline is treated as describing its whole span. + /// + /// The observed split is wide: healthy nights land at 99–100%, the broken ones at 23–93%. 0.95 + /// sits in the empty middle, so the gate is not balanced on the edge of the data. + public static let minCoverage: Double = 0.95 + + /// The covered fraction of `spanSeconds`, or nil when the question does not apply. + /// + /// Returns nil — meaning "unknown, do not judge" — rather than 0 when there is nothing to measure, + /// so an unknown coverage can never be mistaken for a bad one by a caller that compares against + /// `minCoverage`. Clamped to at most 1: segments that overlap or overhang would otherwise report + /// more than a full night, and for a completeness gate the safe direction is to read that as + /// "complete" rather than to invent a failure out of malformed input. + public static func fraction(coveredSeconds: Double, spanSeconds: Double) -> Double? { + guard spanSeconds > 0, coveredSeconds > 0 else { return nil } + return min(1.0, coveredSeconds / spanSeconds) + } + + /// The covered fraction of `spanSeconds` for a session's stored `stagesJSON`, or nil when coverage + /// is not a meaningful question for that payload. + /// + /// nil for: a nil/blank/`"[]"` payload (no stages at all — that is the richness question, not this + /// one), for the IMPORTED minute-dict shape `{light,deep,rem,awake}` and for Health Connect's + /// `{stage,min}` array, neither of which carries timestamps to compare against a span, and for an + /// array holding any non-object element (see the Kotlin twin's loop for why that bails rather than + /// measuring the remainder). + /// + /// SCOPE, precisely. Timestamp-free shapes are what keep this gate off the WHOOP CSV, Apple and + /// Health Connect imports — they are never judged incomplete, so their behaviour is unchanged. That + /// is NOT a blanket exemption for imports, and it was originally written as one: the Xiaomi Band + /// importer emits real `{start,end,stage}` segments, and takes its span from `bedtime`/`wake_up_time` + /// fields that are independent of the `items` array it builds those segments from. A Xiaomi night + /// whose items do not reach its own bed/wake bounds IS judged holed here. That is arguably the right + /// answer — the night genuinely is only partly described — but it is a real behaviour change for + /// that importer, unvalidated against a Xiaomi export, and it is pinned by test rather than left to + /// be discovered. + public static func fraction(stagesJSON: String?, spanSeconds: Double) -> Double? { + fraction(coveredSeconds: coveredSeconds(stagesJSON: stagesJSON), spanSeconds: spanSeconds) + } + + /// Seconds of `stagesJSON` accounted for by timestamped segments, or 0 when the payload carries no + /// measurable ones. + /// + /// Zero, not nil, because this is a SUMMABLE quantity: a caller accumulating several fragments needs + /// "this one contributed nothing" to add cleanly, and the "is coverage even a meaningful question" + /// decision belongs to `fraction`, which turns a zero total back into nil. Callers that want the + /// unknown/known distinction for a SINGLE session must therefore keep using `fraction`, not this. + /// + /// Returns 0 for every shape `fraction(stagesJSON:spanSeconds:)` documents as unmeasurable — a + /// nil/blank/`"[]"` payload, the imported minute-dict `{light,deep,rem,awake}`, Health Connect's + /// `{stage,min}` array, and an array holding any non-object element (the whole payload bails rather + /// than measuring the remainder). Splitting it out is behaviour-preserving for that function: a 0 + /// total fails `fraction`'s `coveredSeconds > 0` guard and still yields nil. + public static func coveredSeconds(stagesJSON: String?) -> Double { + guard let json = stagesJSON?.trimmingCharacters(in: .whitespacesAndNewlines), + !json.isEmpty, json != "[]", + let data = json.data(using: .utf8), + let segs = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] + else { return 0 } + var covered = 0.0 + for seg in segs { + guard let s = (seg["start"] as? NSNumber)?.doubleValue, + let e = (seg["end"] as? NSNumber)?.doubleValue, e > s else { continue } + covered += e - s + } + return covered + } + + /// Coverage summed across a bridged main-night GROUP — the quantity a night is actually judged on. + /// + /// A night is not one row. `AnalyticsEngine.analyzeDay` bridges the day's main-sleep fragments into a + /// group (#561) and accumulates coverage over ALL of them before testing the gate, so a per-row answer + /// is the wrong question for anything presenting a night: two fragments that are individually 90% and + /// 99% covered are one night at neither figure. This exists so a UI can ask the same question the + /// engine asked without restating the accumulation, which is where a twin rule would drift. + /// + /// PARITY WITH THE ENGINE, exactly. Every fragment contributes its full `[startTs, endTs)` to the + /// denominator whether or not its payload is measurable, and contributes cover only from timestamped + /// segments — which reproduces `analyzeDay`'s decoded-segment accumulation term for term, including + /// the mixed-source case it calls out (a timestamp-free fragment adds span and no cover, so a group + /// mixing one with a timestamped fragment reads as holed). A group whose fragments are ALL + /// timestamp-free totals 0 covered seconds and comes back nil — unknown, not holed. + /// The accumulation itself, over `(payload, span)` fragments. This is the shape the Kotlin twin + /// implements — entity-free, because `HypnogramCoverage` sits in `com.noop.analytics` over there and + /// must not reach into `com.noop.data` for a row type. The session overload below is a thin adapter, + /// so both platforms run the identical arithmetic and only the packaging differs. + public static func groupFraction(_ fragments: [(stagesJSON: String?, spanSeconds: Double)]) -> Double? { + var covered = 0.0, span = 0.0 + for f in fragments { + span += f.spanSeconds + covered += coveredSeconds(stagesJSON: f.stagesJSON) + } + return fraction(coveredSeconds: covered, spanSeconds: span) + } + + public static func groupFraction(_ sessions: [CachedSleepSession]) -> Double? { + groupFraction(sessions.map { (stagesJSON: $0.stagesJSON, + spanSeconds: Double($0.endTs - $0.startTs)) }) + } + + /// `isHoled` for a bridged main-night GROUP. Unknown coverage (nil) is NOT holed — same fail-open + /// contract as every other guard here. + public static func isHoledGroup(_ sessions: [CachedSleepSession]) -> Bool { + guard let f = groupFraction(sessions) else { return false } + return f < minCoverage + } + + /// True when the timeline is known to cover less than `minCoverage` of its span — i.e. the session + /// demonstrably describes only part of the night it claims. Unknown coverage (nil) is NOT holed: + /// every guard built on this fails OPEN, so a payload shape this cannot measure keeps its existing + /// behaviour instead of being silently downgraded. + public static func isHoled(stagesJSON: String?, spanSeconds: Double) -> Bool { + guard let f = fraction(stagesJSON: stagesJSON, spanSeconds: spanSeconds) else { return false } + return f < minCoverage + } + + /// `isHoled` for a stored session, using its own `[startTs, endTs)` as the span. + public static func isHoled(_ s: CachedSleepSession) -> Bool { + isHoled(stagesJSON: s.stagesJSON, spanSeconds: Double(s.endTs - s.startTs)) + } +} diff --git a/Packages/WhoopStore/Sources/WhoopStore/MetricsCache.swift b/Packages/WhoopStore/Sources/WhoopStore/MetricsCache.swift index c3ed5fecc4..f519632d38 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/MetricsCache.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/MetricsCache.swift @@ -105,13 +105,16 @@ public struct DailyMetric: Equatable, Codable { /// that column and `SkinTempDisplay.isAbsoluteSkinTemp` separates them by magnitude. This column is /// unambiguous: it is always an absolute, and only the strap pipeline writes it. public let skinTempC: Double? + /// Kotlin twin: `DailyMetric.sleepHrOnly`. Every session that night staged from heart rate alone. + public let sleepHrOnly: Bool? public init(day: String, totalSleepMin: Double?, efficiency: Double?, deepMin: Double?, remMin: Double?, lightMin: Double?, disturbances: Int?, restingHr: Int?, avgHrv: Double?, recovery: Double?, strain: Double?, exerciseCount: Int?, spo2Pct: Double? = nil, skinTempDevC: Double? = nil, respRateBpm: Double? = nil, steps: Int? = nil, activeKcalEst: Double? = nil, spo2Red: Int? = nil, spo2Ir: Int? = nil, avgSdnn: Double? = nil, - skinTempC: Double? = nil) { + skinTempC: Double? = nil, + sleepHrOnly: Bool? = nil) { self.day = day; self.totalSleepMin = totalSleepMin; self.efficiency = efficiency self.deepMin = deepMin; self.remMin = remMin; self.lightMin = lightMin self.disturbances = disturbances; self.restingHr = restingHr; self.avgHrv = avgHrv @@ -120,6 +123,7 @@ public struct DailyMetric: Equatable, Codable { self.steps = steps; self.activeKcalEst = activeKcalEst self.spo2Red = spo2Red; self.spo2Ir = spo2Ir; self.avgSdnn = avgSdnn self.skinTempC = skinTempC + self.sleepHrOnly = sleepHrOnly } /// The freshest STRICTLY-PRIOR day that carries at least one overnight vital (HRV / resting HR / @@ -152,6 +156,41 @@ public struct DailyMetric: Equatable, Codable { public nonisolated static func lastSkinTempDay(days: [DailyMetric], todayKey: String) -> DailyMetric? { days.last(where: { $0.skinTempDevC != nil && $0.day < todayKey }) } + + /// The freshest strictly-prior row carrying EITHER skin-temp number (#1844), so a surface can lead + /// with the absolute and fall back to the deviation from ONE night rather than mixing two. + /// + /// The OR here is deliberate and is NOT the #1842 defect. That bug read field X off a row selected on + /// (X or Y), so a row holding only Y blanked X. This selects a row for a value that is "whichever of + /// the two this night has", and the caller reads both fields off THAT row and lets + /// `SkinTempDisplay.leadReading` pick — so the chosen row always supplies the number shown, and an + /// absolute is never paired with another night's deviation. `lastSkinTempDay` stays as-is for the + /// deviation-only surfaces. Byte-twin of the Android `lastSkinTempReadingRow`. + public nonisolated static func lastSkinTempReadingDay(days: [DailyMetric], todayKey: String) -> DailyMetric? { + days.last(where: { ($0.skinTempC != nil || $0.skinTempDevC != nil) && $0.day < todayKey }) + } + + /// PER-FIELD HRV carry — the twin of `lastSpo2Day` for a field `lastVitalsDay` DOES check, which is + /// precisely why it needs one. That predicate is an OR across HRV / resting-HR / respiratory, so it + /// resolves the freshest row carrying ANY of the three — including a respiratory-only row whose + /// `avgHrv` is nil. The HRV card then reads that nil and renders "—" while a tile carrying a different + /// row shows a real number on the same screen (#1842). Resolving per field picks the freshest row that + /// actually holds an HRV, the same correction `lastSpo2Day` and `lastSkinTempDay` already make. + /// + /// Deliberately NOT staleness-bounded, unlike `Repository.lastRespDay`: an old HRV/RHR carry is + /// disclosed rather than suppressed — the call sites stamp the row's own date and `carriedCaption` + /// relabels a weeks-old one to "Latest sleep" (#779). Respiratory took a hard bound instead because a + /// single CSV import's value was reading as today's for a fortnight (#1331). Same `$0.day < todayKey` + /// future-clock guard; `days` is oldest→newest. Byte-twin of the Android `lastHrvRow`. + public nonisolated static func lastHrvDay(days: [DailyMetric], todayKey: String) -> DailyMetric? { + days.last(where: { $0.avgHrv != nil && $0.day < todayKey }) + } + + /// PER-FIELD resting-HR carry — twin of `lastHrvDay`, same OR-predicate cause and same unbounded-by- + /// design carry (#1842). Byte-twin of the Android `lastRestingHrRow`. + public nonisolated static func lastRestingHrDay(days: [DailyMetric], todayKey: String) -> DailyMetric? { + days.last(where: { $0.restingHr != nil && $0.day < todayKey }) + } } extension WhoopStore { @@ -159,11 +198,29 @@ extension WhoopStore { // MARK: - Upserts (idempotent by natural key; latest server value wins on conflict) /// Upsert cached sleep sessions. Natural key (deviceId, startTs). Returns rows changed. + /// + /// A non-user-edited candidate whose stage timeline is LESS complete than what's already stored for + /// the same (deviceId, startTs) is dropped whole rather than written: a device can serve the same + /// night's hypnogram more than once (a reconnect mid-day, a resumed drain), and a later, shorter + /// decode has no business overwriting an earlier, fuller one — the two would otherwise silently + /// disagree on `stagesJSON` vs. `efficiency`/`endTs` for the same row. `HypnogramCoverage`'s 0/1/2 + /// richness rank (none / holed / covers-its-span) is the same scale `SleepMerge` already judges a + /// day's best session on for DISPLAY; this applies the identical comparison at WRITE time so it + /// protects every caller (BLE live sources, importers, recomputes), not just the ones a caller + /// happens to gate behind their own dedup toggle. @discardableResult public func upsertSleepSessions(_ sessions: [CachedSleepSession], deviceId: String) async throws -> Int { try syncWrite { db in var n = 0 for s in sessions { + if !s.userEdited, let existing = try Self.storedSleepSession(db, deviceId: deviceId, startTs: s.startTs), + !existing.userEdited { + let candidate = CachedSleepSession(startTs: s.startTs, endTs: s.endTs, efficiency: nil, + restingHr: nil, avgHrv: nil, stagesJSON: s.stagesJSON) + if SleepMerge.richness(candidate) < SleepMerge.richness(existing) { + continue // a less-complete re-serve of this night must never clobber the stored one + } + } try db.execute(sql: """ INSERT INTO sleepSession (deviceId, startTs, endTs, efficiency, restingHr, avgHrv, stagesJSON, @@ -192,6 +249,18 @@ extension WhoopStore { } } + /// The stored row at (deviceId, startTs), or nil when there is none yet — the completeness check + /// `upsertSleepSessions` runs before deciding whether a candidate may overwrite it. Only the fields + /// that check needs are read; `efficiency`/`restingHr`/`avgHrv` are left nil since richness never + /// looks at them. + private static func storedSleepSession(_ db: Database, deviceId: String, startTs: Int) throws -> CachedSleepSession? { + guard let row = try Row.fetchOne(db, sql: """ + SELECT endTs, stagesJSON, userEdited FROM sleepSession WHERE deviceId = ? AND startTs = ? + """, arguments: [deviceId, startTs]) else { return nil } + return CachedSleepSession(startTs: startTs, endTs: row["endTs"], efficiency: nil, restingHr: nil, + avgHrv: nil, stagesJSON: row["stagesJSON"], userEdited: row["userEdited"]) + } + /// Hand-correct a sleep session's bed (onset) and/or wake (end) time. Sets `userEdited = 1` so the /// next recompute/import `upsertSleepSessions` preserves the corrected bounds instead of overwriting /// them with the strap-detected values. Keyed by the stable detected natural key @@ -430,8 +499,8 @@ extension WhoopStore { (deviceId, day, totalSleepMin, efficiency, deepMin, remMin, lightMin, disturbances, restingHr, avgHrv, recovery, strain, exerciseCount, spo2Pct, skinTempDevC, respRateBpm, steps, activeKcalEst, - spo2Red, spo2Ir, avgSdnn, skinTempC) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + spo2Red, spo2Ir, avgSdnn, skinTempC, sleepHrOnly) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(deviceId, day) DO UPDATE SET totalSleepMin = excluded.totalSleepMin, efficiency = excluded.efficiency, @@ -452,13 +521,14 @@ extension WhoopStore { spo2Red = excluded.spo2Red, spo2Ir = excluded.spo2Ir, avgSdnn = excluded.avgSdnn, - skinTempC = excluded.skinTempC + skinTempC = excluded.skinTempC, + sleepHrOnly = excluded.sleepHrOnly """, arguments: [deviceId, d.day, d.totalSleepMin, d.efficiency, d.deepMin, d.remMin, d.lightMin, d.disturbances, d.restingHr, d.avgHrv, d.recovery, d.strain, d.exerciseCount, d.spo2Pct, d.skinTempDevC, d.respRateBpm, d.steps, d.activeKcalEst, - d.spo2Red, d.spo2Ir, d.avgSdnn, d.skinTempC]) + d.spo2Red, d.spo2Ir, d.avgSdnn, d.skinTempC, d.sleepHrOnly]) n += db.changesCount } return n @@ -481,6 +551,23 @@ extension WhoopStore { } } + /// #1853: FILL-ONLY write of `skinTempC` for nights the backfill re-derived an absolute for. + /// Updates ONLY the `skinTempC` column of an existing row, and ONLY when that column is currently + /// NULL — never an upsert of a rebuilt row, never overwriting a measured value. A single-column + /// UPDATE is deliberate: the backfill must not touch `skinTempDevC` or any other scored field, and + /// a full-row upsert would risk clobbering a concurrent scoring pass's write. Returns rows filled. + @discardableResult + public func fillSkinTempC(deviceId: String, day: String, skinTempC: Double) async throws -> Int { + try syncWrite { db in + try db.execute(sql: """ + UPDATE dailyMetric + SET skinTempC = ? + WHERE deviceId = ? AND day = ? AND skinTempC IS NULL + """, arguments: [skinTempC, deviceId, day]) + return db.changesCount + } + } + // MARK: - Reads /// Cached sleep sessions overlapping [from, to] (by startTs), oldest first. @@ -509,7 +596,7 @@ extension WhoopStore { SELECT day, totalSleepMin, efficiency, deepMin, remMin, lightMin, disturbances, restingHr, avgHrv, recovery, strain, exerciseCount, spo2Pct, skinTempDevC, respRateBpm, steps, activeKcalEst, - spo2Red, spo2Ir, avgSdnn, skinTempC FROM dailyMetric + spo2Red, spo2Ir, avgSdnn, skinTempC, sleepHrOnly FROM dailyMetric WHERE deviceId = ? AND day >= ? AND day <= ? ORDER BY day ASC """, arguments: [deviceId, from, to]) @@ -524,7 +611,8 @@ extension WhoopStore { respRateBpm: $0["respRateBpm"], steps: $0["steps"], activeKcalEst: $0["activeKcalEst"], spo2Red: $0["spo2Red"], spo2Ir: $0["spo2Ir"], avgSdnn: $0["avgSdnn"], - skinTempC: $0["skinTempC"]) + skinTempC: $0["skinTempC"], + sleepHrOnly: $0["sleepHrOnly"]) } } } diff --git a/Packages/WhoopStore/Sources/WhoopStore/RawOutbox.swift b/Packages/WhoopStore/Sources/WhoopStore/RawOutbox.swift index a08a53a9aa..c717bac61e 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/RawOutbox.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/RawOutbox.swift @@ -1,14 +1,9 @@ +#if canImport(Compression) import Foundation import Compression import GRDB import WhoopProtocol -public struct ClockRef: Equatable, Codable { - public let device: Int - public let wall: Int - public init(device: Int, wall: Int) { self.device = device; self.wall = wall } -} - public struct RawBatchMeta: Equatable { public let batchId: String public let deviceId: String @@ -252,3 +247,4 @@ extension WhoopStore { } } } +#endif diff --git a/Packages/WhoopStore/Sources/WhoopStore/Reads.swift b/Packages/WhoopStore/Sources/WhoopStore/Reads.swift index 73a3971387..143e6e5ec6 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/Reads.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/Reads.swift @@ -60,6 +60,23 @@ extension WhoopStore { } } + /// Standard-BLE sensor-contact readings recorded alongside generic HR samples. Rows before this + /// feature have no contact event, so this intentionally returns no invented legacy values. + public func standardHRContacts(deviceId: String, from: Int, to: Int, + limit: Int) async throws -> [StandardHRContactSample] { + try syncRead { db in + try Row.fetchAll(db, sql: """ + SELECT ts, payloadJSON FROM event + WHERE deviceId = ? AND kind = ? AND ts >= ? AND ts <= ? + ORDER BY ts ASC LIMIT ? + """, arguments: [deviceId, StandardHRMapping.contactEventKind, from, to, limit]) + .map { row in + let json: String = row["payloadJSON"] + return try StandardHRMapping.contactSample(ts: row["ts"], payloadJSON: json) + } + } + } + /// Cheap change-detector for the raw HR stream: `(count, maxTs)` over `[from, to]`, computed in /// SQLite over the `(deviceId, ts)` index WITHOUT materializing any rows (#836). Lets a caller decide /// "nothing was inserted since last time, skip the expensive re-read" for pennies, `COUNT(*)` moves on @@ -99,6 +116,106 @@ extension WhoopStore { } } + /// Cross-device change detector for every raw stream that can change a daily score or sleep session. + /// + /// The original analysis watermark covered `hrSample` only. Historical offloads do not commit their + /// streams atomically: HR can land first and motion/gravity later. A pass run between those commits sees + /// no usable sleep, then the post-offload gate used to skip the decisive retry because HR itself had not + /// changed. Fingerprint the complete scoring input instead. HR keeps its established count+timestamp + /// fingerprint; the other streams use SQLite's monotonic rowid frontier, which catches old backfills + /// without full-table COUNT scans over millions of dense motion/R-R rows. + /// `v2` intentionally changes the persisted watermark once on upgrade so every install gets one clean + /// rescore under the complete contract. + public func analysisFingerprint() async throws -> String { + try syncRead { db in + guard let row = try Row.fetchOne(db, sql: """ + SELECT + (SELECT COUNT(*) FROM hrSample) AS hc, + (SELECT COALESCE(MAX(ts), 0) FROM hrSample) AS hm, + (SELECT COALESCE(MAX(rowid), 0) FROM ppgHrSample) AS p, + (SELECT COALESCE(MAX(rowid), 0) FROM rrInterval) AS r, + (SELECT COALESCE(MAX(rowid), 0) FROM respSample) AS x, + (SELECT COALESCE(MAX(rowid), 0) FROM gravitySample) AS g, + (SELECT COALESCE(MAX(rowid), 0) FROM sleepStateSample) AS s, + (SELECT COALESCE(MAX(rowid), 0) FROM event) AS e, + (SELECT COALESCE(MAX(rowid), 0) FROM spo2Sample) AS o, + (SELECT COALESCE(MAX(rowid), 0) FROM skinTempSample) AS t, + (SELECT COALESCE(MAX(rowid), 0) FROM stepSample) AS z + """) else { return "" } + let hc: Int = row["hc"], hm: Int = row["hm"] + let keys = ["p", "r", "x", "g", "s", "e", "o", "t", "z"] + let tails = keys.map { key -> String in + let frontier: Int = row[key] + return key + String(frontier) + } + return "v2|h\(hc):\(hm)|" + tails.joined(separator: "|") + } + } + + /// Per-day, per-owner change detector for every scored stream that [hrFingerprint] does NOT witness. + /// + /// [analysisFingerprint] answers "did anything change anywhere"; this answers "did THIS night's scored + /// input change", which is the question the `analyzeRecent` per-day reuse cache asks. A history offload + /// does not commit its channels together — HR can land first and R-R, respiration or SpO2 minutes later, + /// and an offloaded HR row duplicating a live one is dropped by `ON CONFLICT DO NOTHING` — so a night + /// scored once from HR alone can gain its R-R with `(count, maxTs)` over `hrSample` completely unmoved + /// (#29). Keyed on HR alone the cache re-served that HRV-less scan for the rest of the session. + /// + /// One statement, one index range walk per stream over the same `(deviceId, ts)` primary keys + /// [hrFingerprint] uses, materialising no rows. `COUNT(*)` is the load-bearing half: it moves when a + /// backfill lands rows INSIDE a window already covered, which `MAX(ts)` alone would miss. + /// + /// The streams are exactly the ones the per-day loop reads and hands to `analyzeDay`: + /// - `ppgHrSample`: the day's HR read is measured ∪ PPG-derived ([hrSamples]), so a PPG row for a second + /// with no measured HR changes the scored series while `hrSample` stays put. + /// - `rrInterval`: filtered exactly as [rrIntervals] filters at read (the 0x6E SpO2-IBI duplicate and + /// future-stamped beats are excluded), so the witness counts the beats that are actually scored. + /// - `sleepStateSample`: appended from the SAME v18 record at the same `ts` as HR, so a re-offloaded + /// record whose HR row is dropped on conflict still lands a new band row that `analyzeDay` scores. + /// Its letter is `b` (band), not `s` — `s1|` is the version prefix. + /// - `respSample`, `spo2Sample`, `gravitySample`, `stepSample`, `skinTempSample`, `event`. + /// + /// Returned as an opaque string: it is only ever compared to itself in memory, so no cross-platform or + /// cross-launch byte identity is required. The Kotlin twin is `WhoopDao.dayStreamFingerprint`. + public func dayStreamFingerprint(deviceId: String, from: Int, to: Int) async throws -> String { + try syncRead { db in + // Every sub-select is COUNT/COALESCE(MAX(...), 0), so each column is non-null and the aggregate + // query always returns exactly one row — same idiom as `analysisFingerprint` above. + guard let row = try Row.fetchOne(db, sql: """ + SELECT + (SELECT COUNT(*) FROM ppgHrSample WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS pc, + (SELECT COALESCE(MAX(ts), 0) FROM ppgHrSample WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS pm, + (SELECT COUNT(*) FROM rrInterval WHERE deviceId = :d AND ts >= :f AND ts <= :t + AND (srcChannel IS NULL OR srcChannel <> :rrx) + AND (tsSuspect IS NULL OR tsSuspect <> 1)) AS rc, + (SELECT COALESCE(MAX(ts), 0) FROM rrInterval WHERE deviceId = :d AND ts >= :f AND ts <= :t + AND (srcChannel IS NULL OR srcChannel <> :rrx) + AND (tsSuspect IS NULL OR tsSuspect <> 1)) AS rm, + (SELECT COUNT(*) FROM respSample WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS xc, + (SELECT COALESCE(MAX(ts), 0) FROM respSample WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS xm, + (SELECT COUNT(*) FROM spo2Sample WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS oc, + (SELECT COALESCE(MAX(ts), 0) FROM spo2Sample WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS om, + (SELECT COUNT(*) FROM gravitySample WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS gc, + (SELECT COALESCE(MAX(ts), 0) FROM gravitySample WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS gm, + (SELECT COUNT(*) FROM stepSample WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS zc, + (SELECT COALESCE(MAX(ts), 0) FROM stepSample WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS zm, + (SELECT COUNT(*) FROM skinTempSample WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS tc, + (SELECT COALESCE(MAX(ts), 0) FROM skinTempSample WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS tm, + (SELECT COUNT(*) FROM sleepStateSample WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS bc, + (SELECT COALESCE(MAX(ts), 0) FROM sleepStateSample WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS bm, + (SELECT COUNT(*) FROM event WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS ec, + (SELECT COALESCE(MAX(ts), 0) FROM event WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS em + """, arguments: ["d": deviceId, "f": from, "t": to, + "rrx": RRSourceChannel.spo2Ibi.rawValue]) else { return "" } + let keys = ["p", "r", "x", "o", "g", "z", "t", "b", "e"] + let parts = keys.map { key -> String in + let count: Int = row[key + "c"], maxTs: Int = row[key + "m"] + return "\(key)\(count):\(maxTs)" + } + return "s1|" + parts.joined(separator: "|") + } + } + /// Aggregate HR over a window: `(n, avg, max)` computed in SQLite over the same measured-∪-PPG rows /// [hrSamples] returns, WITHOUT materialising them and WITHOUT a row limit. /// @@ -216,7 +333,7 @@ extension WhoopStore { public func rrIntervals(deviceId: String, from: Int, to: Int, limit: Int) async throws -> [RRInterval] { try syncRead { db in try Row.fetchAll(db, sql: """ - SELECT ts, rrMs, srcChannel, ord FROM rrInterval + SELECT ts, rrMs, srcChannel, ord, seq FROM rrInterval WHERE deviceId = ? AND ts >= ? AND ts <= ? AND (srcChannel IS NULL OR srcChannel <> ?) AND (tsSuspect IS NULL OR tsSuspect <> 1) -- #1073: exclude future-stamped beats @@ -225,7 +342,7 @@ extension WhoopStore { .map { row in RRInterval(ts: row["ts"], rrMs: row["rrMs"], srcChannel: (row["srcChannel"] as Int?).flatMap(RRSourceChannel.init(rawValue:)), - ord: row["ord"] as Int?) + ord: row["ord"] as Int?, seq: row["seq"]) } } } @@ -297,6 +414,75 @@ extension WhoopStore { } } + /// Kotlin twin: `WhoopDao.stepSamplesPage`. + public func stepSamplesPage(deviceId: String, afterExclusive: Int, endExclusive: Int, + limit: Int) async throws -> [StepSample] { + try syncRead { db in + try Row.fetchAll(db, sql: """ + SELECT ts, counter, activityClass FROM stepSample + WHERE deviceId = ? AND ts > ? AND ts < ? + ORDER BY ts ASC LIMIT ? + """, arguments: [deviceId, afterExclusive, endExclusive, limit]).map { + StepSample(ts: $0["ts"], counter: $0["counter"], activityClass: $0["activityClass"]) + } + } + } + + /// Last counter sample before a cycle boundary, used to attribute the first in-cycle delta correctly. + /// Kotlin twin: `WhoopDao.stepSampleBefore`. + public func stepSampleBefore(deviceId: String, before: Int) async throws -> StepSample? { + try syncRead { db in + try Row.fetchOne(db, sql: """ + SELECT ts, counter, activityClass FROM stepSample + WHERE deviceId = ? AND ts < ? ORDER BY ts DESC LIMIT 1 + """, arguments: [deviceId, before]).map { + StepSample(ts: $0["ts"], counter: $0["counter"], activityClass: $0["activityClass"]) + } + } + } + + /// Kotlin twin: `WhoopDao.hasStepActivityClasses`. + public func hasStepActivityClasses(deviceId: String, from: Int, to: Int) async throws -> Bool { + try syncRead { db in + try Int.fetchOne(db, sql: """ + SELECT EXISTS(SELECT 1 FROM stepSample + WHERE deviceId = ? AND ts >= ? AND ts < ? AND activityClass IS NOT NULL) + """, arguments: [deviceId, from, to]) == 1 + } + } + + /// Kotlin twin: `WhoopRepository.stepTimestampCoverage`. + public func stepTimestampCoverage(deviceId: String, from: Int, to: Int) async throws + -> (first: Int?, last: Int?) { + try syncRead { db in + let row = try Row.fetchOne(db, sql: """ + SELECT MIN(ts) AS firstTs, MAX(ts) AS lastTs FROM stepSample + WHERE deviceId = ? AND ts >= ? AND ts < ? + """, arguments: [deviceId, from, to]) + return (row?["firstTs"], row?["lastTs"]) + } + } + + /// Bounded process-local invalidator over the UTC days touched by this window. The instance token + /// prevents a static cycle cache surviving a store reopen from reusing revisions from the old DB. + /// Kotlin twin: `WhoopRepository.stepDataRevisionSignature`. + public func stepDataRevisionSignature(deviceId: String, from: Int, to: Int) async -> String { + "\(revisionInstanceToken):\(deviceId):\(stepDataRevision.signature(deviceId: deviceId, from: from, to: to))" + } + + public func stepDiagnosticMotionCounts(deviceId: String, from: Int, to: Int) async throws + -> (gravity: Int, aux: Int) { + try syncRead { db in + let gravity = try Int.fetchOne(db, sql: """ + SELECT COUNT(*) FROM gravitySample WHERE deviceId = ? AND ts >= ? AND ts < ? + """, arguments: [deviceId, from, to]) ?? 0 + let aux = try Int.fetchOne(db, sql: """ + SELECT COUNT(*) FROM v18AuxSample WHERE deviceId = ? AND ts >= ? AND ts < ? + """, arguments: [deviceId, from, to]) ?? 0 + return (gravity, aux) + } + } + public func respSamples(deviceId: String, from: Int, to: Int, limit: Int) async throws -> [RespSample] { try syncRead { db in try Row.fetchAll(db, sql: """ @@ -323,6 +509,41 @@ extension WhoopStore { /// is an index-ONLY scan (both columns live in that index, so no table rows are touched) with /// `deviceId` leading, which also lets the GROUP BY skip a temp b-tree. Cheap enough for a /// user-triggered diagnostics export, which is the only caller. + /// Which raw streams a device has ANY rows for in a window — the strap-capability question, + /// answered without counting. + /// + /// EXISTS rather than COUNT on purpose. The question is presence, every one of these tables is keyed + /// `(deviceId, ts)`, so each subquery is an index seek that stops at the first row instead of walking a + /// night's worth — a 4.0 night carries ~190k gravity rows and counting them to learn "yes" would be + /// absurd. One statement so it is one round trip. + /// + /// Answers what no existing line could: a strap streaming live HR with no motion cannot produce a + /// staged night or an auto-detected workout, and until now a reader had to infer that from the absence + /// of other lines. Diagnostics-export only, like `rawSampleCountsByDevice`. Kotlin twin: + /// `WhoopDao.streamPresence`. + public struct StreamPresence: Sendable, Equatable { + public let hr: Bool, rr: Bool, gravity: Bool, steps: Bool + public init(hr: Bool, rr: Bool, gravity: Bool, steps: Bool) { + self.hr = hr; self.rr = rr; self.gravity = gravity; self.steps = steps + } + } + + public func streamPresence(deviceId: String, from: Int, to: Int) async throws -> StreamPresence { + try syncRead { db in + let row = try Row.fetchOne(db, sql: """ + SELECT + EXISTS(SELECT 1 FROM hrSample WHERE deviceId = ? AND ts >= ? AND ts <= ?) AS hr, + EXISTS(SELECT 1 FROM rrInterval WHERE deviceId = ? AND ts >= ? AND ts <= ?) AS rr, + EXISTS(SELECT 1 FROM gravitySample WHERE deviceId = ? AND ts >= ? AND ts <= ?) AS gravity, + EXISTS(SELECT 1 FROM stepSample WHERE deviceId = ? AND ts >= ? AND ts <= ?) AS steps + """, arguments: [deviceId, from, to, deviceId, from, to, + deviceId, from, to, deviceId, from, to]) + return StreamPresence( + hr: (row?["hr"] ?? 0) != 0, rr: (row?["rr"] ?? 0) != 0, + gravity: (row?["gravity"] ?? 0) != 0, steps: (row?["steps"] ?? 0) != 0) + } + } + public func rawSampleCountsByDevice(from: Int, to: Int) async throws -> [(String, Int)] { try syncRead { db in try Row.fetchAll(db, sql: """ @@ -384,28 +605,132 @@ extension WhoopStore { } } + /// Per-table row counts for every accumulating decoded stream, keyed identically to Android's + /// `WhoopRepository.storageRowCounts` so a maintainer reads the SAME map from either platform. + /// + /// #1911 wants to know WHICH table holds a multi-gigabyte database, and the answer was unavailable on + /// Apple: the Test Centre probe emitted nine keys and left out `ppgHr`, `sleepState`, `ppgWaveform` + /// and `v18Aux` — the four Android's own comment singles out as "can each be large", and the ones a + /// row-count model misprices worst, since `ppgWaveformSample` is the only blob table here. A footprint + /// that omits the blob table cannot attribute the bytes it was collected to explain. + /// + /// Shares `rawTableKeys` with `storageStats()` and `TimestampHeal` so a table added to one is seen by + /// all three — counted in the total, named in the breakdown, and healed. Best-effort + /// per table: an unreadable count is omitted rather than reported as zero, because a zero here reads + /// as "this table is empty" and that is a different claim from "this table could not be read". + public func storageRowCounts() async throws -> [String: Int] { + try syncRead { db in + var out: [String: Int] = [:] + for (key, table) in Self.rawTableKeys { + if let n = try? Int.fetchOne(db, sql: "SELECT COUNT(*) FROM \(table)") { out[key] = n } + } + return out + } + } + + /// Estimated payload bytes per decoded stream table, keyed as `storageRowCounts()` keys. + /// + /// #1911 asks which table holds a multi-gigabyte database, and row counts alone cannot answer it: the + /// twelve fixed-width tables are comparable by rows, but `ppgWaveformSample` stores a BLOB whose size + /// varies, and it is the table most likely to hold bytes a per-second row model does not predict. So + /// this measures the blob rather than assuming it. + /// + /// Sampled, not scanned: the mean payload of up to `sampleRows` rows, multiplied by the count. A full + /// `SUM(LENGTH(...))` over a 6.5 GB store is exactly the kind of read this diagnostic exists to let + /// someone avoid, and an estimate answers "which table dominates" just as well. + /// + /// What it measures: BLOB and TEXT columns by their real byte/character length, numeric columns at a + /// nominal 8. It EXCLUDES index pages, page slack and the WAL, so it is a payload lower bound, not the + /// file size — compare it against `db_bytes` in the same probe rather than expecting the two to meet. + /// An unreadable table is omitted, on the same reasoning as the row counts: absent is not zero. + /// `rowCounts` lets a caller that already has them - the Test Centre probe does - avoid a second + /// `COUNT(*)` pass over every table. That matters here more than it usually would: this diagnostic is + /// read on the multi-gigabyte stores it exists to explain, where each count is a full scan, so a tool + /// that counts the same thirteen tables three times is slowest exactly where it is needed. + public func storageByteEstimates(sampleRows: Int = 500, + rowCounts: [String: Int]? = nil) async throws -> [String: Int] { + try syncRead { db in + var out: [String: Int] = [:] + for (key, table) in Self.rawTableKeys { + let known = rowCounts?[key] + guard let rows = known ?? (try? Int.fetchOne(db, sql: "SELECT COUNT(*) FROM \(table)")), + rows > 0 + else { continue } + let cols = try? Row.fetchAll(db, sql: "PRAGMA table_info(\(table))") + guard let cols, !cols.isEmpty else { continue } + // Table and column names come from the schema, never from user input, so interpolating + // them is safe here in the same way the COUNT above is. + // `typeof()` at runtime, not the DECLARED type, and byte length via CAST rather than + // LENGTH: SQLite is dynamically typed, so a column declared INTEGER can hold text, and + // LENGTH on text counts CHARACTERS while the store spends bytes. Mirrors the expression + // Kotlin already uses in `PushSnapshotPreflight.rowEstimateExpression`. + let terms = cols.compactMap { c -> String? in + guard let name = c["name"] as String? else { return nil } + let q = "\"\(name)\"" + return "(CASE WHEN \(q) IS NULL THEN 0 " + + "WHEN typeof(\(q)) IN ('text','blob') THEN length(CAST(\(q) AS BLOB)) " + + "ELSE 8 END)" + } + guard !terms.isEmpty else { continue } + let sql = "SELECT AVG(n) FROM (SELECT (\(terms.joined(separator: " + "))) AS n " + // Clamped: SQLite reads a NEGATIVE limit as no limit at all, so an unchecked value + // here would full-scan the very tables the sampling exists to avoid scanning. + + "FROM \(table) LIMIT \(max(1, sampleRows)))" + if let mean = try? Double.fetchOne(db, sql: sql), mean.isFinite, mean >= 0 { + out[key] = Int((mean * Double(rows)).rounded()) + } + } + return out + } + } + + /// The decoded stream tables and the key each reports under. The keys are Android's, verbatim. + static let rawTableKeys: [(String, String)] = [ + ("hr", "hrSample"), ("rr", "rrInterval"), ("events", "event"), ("battery", "battery"), + ("spo2", "spo2Sample"), ("skinTemp", "skinTempSample"), ("resp", "respSample"), + ("gravity", "gravitySample"), ("steps", "stepSample"), ("ppgHr", "ppgHrSample"), + ("sleepState", "sleepStateSample"), ("ppgWaveform", "ppgWaveformSample"), + ("v18Aux", "v18AuxSample"), + ] + + /// The raw outbox alone: batch count and total byteSize, with NO decoded-table counting. + /// + /// `storageStats()` returns these beside a 13-table `COUNT(*)` sweep, and the Test Centre probe was + /// calling it purely for `rawBytes` and discarding the rest - thirteen full scans, on the large stores + /// where a scan is least free, for two numbers that touch a different table entirely. + public func rawOutboxStats() async throws -> (batches: Int, bytes: Int) { + try syncRead { try Self.rawOutbox($0) } + } + + /// The outbox pair against an OPEN database, so `storageStats()` and `rawOutboxStats()` share one + /// implementation. It has to take `db` rather than call the other: `syncRead` is `dbWriter.read`, and + /// a read nested inside a read deadlocks - so a plain helper is the only way these two stop being a + /// copy of each other, and a test pinning that a copy agrees is a weaker thing than not having one. + private static func rawOutbox(_ db: Database) throws -> (batches: Int, bytes: Int) { + let batches = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM rawBatch") ?? 0 + let bytes = try Int.fetchOne(db, sql: "SELECT COALESCE(SUM(byteSize), 0) FROM rawBatch") ?? 0 + return (batches, bytes) + } + /// Aggregate storage footprint: total decoded rows, raw batch count, total raw byteSize. public func storageStats() async throws -> (decodedRows: Int, rawBatches: Int, rawBytes: Int) { try syncRead { db in - // The COMPLETE set of accumulating decoded raw streams — KEEP IN SYNC with - // `TimestampHeal.rawTables` (its per-timestamp purge is the canonical list) and the Android - // `WhoopRepository.storageRowCounts`. Summed by iterating the list rather than a hand-written - // expression, because the old fixed sum silently under-reported: it omitted stepSample, - // ppgHrSample, sleepStateSample, ppgWaveformSample, rawImuSample and v18AuxSample — and a 4.0 - // with PPG (ppgHrSample/ppgWaveformSample) or IMU capture (rawImuSample) banks millions of rows. + // The COMPLETE set of accumulating decoded raw streams, from `rawTableKeys` — no longer a + // "keep in sync with TimestampHeal" instruction, because that copy is gone and the heal now + // reads the same list. Android's `WhoopRepository.storageRowCounts` is the one that still has + // to be kept in step by hand; the note there says so. + // + // Summed by iterating the list rather than a hand-written expression, because the old fixed + // sum silently under-reported: it omitted stepSample, ppgHrSample, sleepStateSample, + // ppgWaveformSample, rawImuSample and v18AuxSample — and a 4.0 with PPG banks millions of + // rows. (rawImuSample has since been dropped outright, in `v41-drop-raw-imu-sample`.) // Table names are compile-time constants (never user input), so the interpolation is safe. - let rawTables = ["hrSample", "rrInterval", "event", "battery", - "spo2Sample", "skinTempSample", "respSample", "gravitySample", - "stepSample", "ppgHrSample", "sleepStateSample", "ppgWaveformSample", - "rawImuSample", "v18AuxSample"] var decoded = 0 - for t in rawTables { + for (_, t) in Self.rawTableKeys { decoded += try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM \(t)") ?? 0 } - let batches = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM rawBatch") ?? 0 - let bytes = try Int.fetchOne(db, - sql: "SELECT COALESCE(SUM(byteSize), 0) FROM rawBatch") ?? 0 - return (decoded, batches, bytes) + let outbox = try Self.rawOutbox(db) + return (decoded, outbox.batches, outbox.bytes) } } } diff --git a/Packages/WhoopStore/Sources/WhoopStore/ScoreInputProvenanceStore.swift b/Packages/WhoopStore/Sources/WhoopStore/ScoreInputProvenanceStore.swift index c246fc18bb..ce0431767c 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/ScoreInputProvenanceStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/ScoreInputProvenanceStore.swift @@ -1,6 +1,12 @@ import Foundation import GRDB +public struct SourcedMetricPoint: Sendable { + public let deviceId: String + public let point: MetricPoint + public init(deviceId: String, point: MetricPoint) { self.deviceId = deviceId; self.point = point } +} + /// Provenance for one persisted NOOP-computed score. `sourceId` normally names the input provider; /// `vo2max_est` uses the estimator id because the method is the provenance users need for that series. /// Natural key: (computed device namespace, day, metric key). @@ -37,7 +43,10 @@ extension WhoopStore { provenance: [ScoreInputProvenanceRow], deviceId: String, from: String, - to: String + to: String, + replaceMetricKeys: [String] = [], + additionalMetricPoints: [SourcedMetricPoint] = [], + replaceMetricSourceIds: [String] = [] ) async throws { // #1196: an empty scoring pass must not destructively rewrite the window — with no daily rows to // write, the provenance wide-delete below would blank the window's attribution while a degenerate @@ -47,7 +56,16 @@ extension WhoopStore { guard !dailyMetrics.isEmpty else { return } try syncWrite { db in _ = try Self.upsertDailyMetrics(dailyMetrics, deviceId: deviceId, in: db) + for source in Set(replaceMetricSourceIds + [deviceId]) { + for key in replaceMetricKeys { + try db.execute(sql: "DELETE FROM metricSeries WHERE deviceId = ? AND key = ? AND day >= ? AND day <= ?", + arguments: [source, key, from, to]) + } + } _ = try Self.upsertMetricSeries(metricPoints, deviceId: deviceId, in: db) + for group in Dictionary(grouping: additionalMetricPoints, by: \.deviceId) { + _ = try Self.upsertMetricSeries(group.value.map(\.point), deviceId: group.key, in: db) + } // Weekly VO₂max provenance is owned by `persistMetricSeriesWithProvenance` below, not this // daily scoring window. Preserve it or every normal 21-day re-score erases the prior two diff --git a/Packages/WhoopStore/Sources/WhoopStore/SleepMerge.swift b/Packages/WhoopStore/Sources/WhoopStore/SleepMerge.swift index ecbbcd8d5f..d55d851927 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/SleepMerge.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/SleepMerge.swift @@ -16,6 +16,23 @@ public enum SleepMerge { /// where neither side does, keep the imported-over-computed rule unchanged. (Swift twin of the /// Android HealthConnectImporter richness fix, ryanbr/noop#240.) /// + /// Richness is a RANK, not a yes/no. Presence alone was the original test, and it cannot see a + /// device-provided hypnogram assembled from records that arrived incomplete: such a row carries many + /// segments — so it passes any presence check — while covering a fraction of the span it claims + /// (measured: one ring night with 21 segments over 23% of its 601-minute span, stored as 70 minutes + /// of sleep against a paired strap's 494). So a day's best session ranks: + /// + /// 2 stages that cover the span they claim (`HypnogramCoverage`) + /// 1 stages present but HOLED — real, but describing only part of the night + /// 0 no stages at all + /// + /// and the computed day wins only when it OUT-RANKS the import. Collapsing 1 and 0 together gives + /// back the original presence rule exactly, so this is a strict generalisation: every case #240 + /// decided, it still decides the same way. What changes is the pair the old rule could not express + /// — a holed import against a COMPLETE computed night, which used to go to the import and now goes + /// to the night that actually covers itself. A holed import still beats no stages at all: partial + /// data is better than none, it just no longer outranks a whole night. + /// /// - Parameter endDay: maps a session to its canonical LOCAL end-day key (callers inject their /// timezone-aware keyer so this stays pure and testable). public static func merge(imported: [CachedSleepSession], @@ -30,8 +47,7 @@ public enum SleepMerge { out.reserveCapacity(imported.count + computed.count) for (day, imp) in importedByDay { if let comp = computedByDay[day], - !imp.contains(where: hasStages), - comp.contains(where: hasStages) { + dayRichness(comp) > dayRichness(imp) { out.append(contentsOf: comp) // richer computed day survives a stage-less import } else { out.append(contentsOf: imp) // imported wins its day (unchanged rule) @@ -48,4 +64,19 @@ public enum SleepMerge { guard let json = s.stagesJSON?.trimmingCharacters(in: .whitespacesAndNewlines) else { return false } return !json.isEmpty && json != "[]" } + + /// How much of a night this session's stages actually describe: 2 = covers its span, 1 = present but + /// holed, 0 = none. A timeline whose coverage cannot be measured (the imported minute-dict shape, + /// which has no timestamps) is never holed, so it ranks 2 — imports keep being judged on presence + /// exactly as they always were, and this gate cannot reach them. + static func richness(_ s: CachedSleepSession) -> Int { + guard hasStages(s) else { return 0 } + return HypnogramCoverage.isHoled(s) ? 1 : 2 + } + + /// A day's richness is that of its BEST session — a day with a fully-staged main night plus an + /// unstaged nap is a staged day (#715 keeps every session of the winning day either way). + static func dayRichness(_ sessions: [CachedSleepSession]) -> Int { + sessions.reduce(0) { max($0, richness($1)) } + } } diff --git a/Packages/WhoopStore/Sources/WhoopStore/SourceIdentity.swift b/Packages/WhoopStore/Sources/WhoopStore/SourceIdentity.swift new file mode 100644 index 0000000000..cf9a936602 --- /dev/null +++ b/Packages/WhoopStore/Sources/WhoopStore/SourceIdentity.swift @@ -0,0 +1,48 @@ +import Foundation + +/// Which registry device a live BLE link's samples belong to (#1881). +/// +/// Split out of `BLEManager` because it is the whole of the decision and none of the CoreBluetooth: BLE +/// behaviour cannot be tested in CI, but *this* can, and it is the part that silently files rows under the +/// wrong device when it is wrong. The Kotlin twin is `com.noop.ble.SourceIdentity`. +/// +/// The bug it exists to prevent: `BLEManager` held one `deviceId` meaning "the active device" and reused it +/// as "the device these bytes came from". Those were the same thing only while every registered device was +/// a WHOOP. Once a ring could be active, a WHOOP connection filed its samples — including `stepSample` and +/// `gravitySample`, which only a strap can produce — under the ring's id. +public enum SourceIdentity { + + /// The device id this connection's samples should be stored under, or nil to leave the current id alone. + /// + /// Conservative by construction. It returns an id ONLY when a registry row is both matched by peripheral + /// and a WHOOP, so every uncertain case keeps today's behaviour rather than guessing: + /// • `address` nil, or no row carries it → nil. This is the legacy single-WHOOP row before it has + /// adopted a `peripheralId`; the coordinator adopts one on this same connect, so the next resolves. + /// • the matched row is not a WHOOP → nil. Nothing else should be arriving on the WHOOP delegate, and + /// if it does, re-pointing the strap's id at it would be the very bug this prevents. + /// • the matched row is already the current id → nil, so there is no write on the common path. + /// + /// Matching is case-insensitive because the two platforms disagree about case: Apple stores an uppercase + /// `CBPeripheral.identifier.uuidString`, Android an uppercase MAC — but neither is guaranteed by the OS, + /// and a row written by an older build may carry either. + public static func resolve(address: String?, rows: [PairedDevice], currentId: String) -> String? { + // BLANK, not merely empty, so the two platforms agree: Kotlin's `isNullOrBlank` refuses a + // whitespace-only address, and a bare `isEmpty` here would let one match a stored blank. + guard let address, !address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return nil + } + guard let row = rows.first(where: { + guard let pid = $0.peripheralId else { return false } + return pid.caseInsensitiveCompare(address) == .orderedSame + }) else { return nil } + guard isWhoop(row), row.id != currentId else { return nil } + return row.id + } + + /// A device is a WHOOP when it is the seeded legacy row or its brand says so. Kept here beside the + /// resolver rather than reaching into the app target, so this file has no dependency to test around. + /// Byte-identical to `SourceCoordinator.isWhoop` on both platforms. + public static func isWhoop(_ device: PairedDevice) -> Bool { + device.id == "my-whoop" || device.brand.caseInsensitiveCompare("WHOOP") == .orderedSame + } +} diff --git a/Packages/WhoopStore/Sources/WhoopStore/StandardHRMapping.swift b/Packages/WhoopStore/Sources/WhoopStore/StandardHRMapping.swift index 229d03ed57..287035c07a 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/StandardHRMapping.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/StandardHRMapping.swift @@ -8,15 +8,68 @@ import WhoopProtocol /// can't be unit-tested. /// /// A chest strap (Polar / Wahoo / Coospo / Garmin HRM / Amazfit Helio broadcast) only ever -/// reports HR and (optionally) R-R intervals over 0x2A37; every other stream (spo2, skin temp, -/// resp, gravity, steps, ppgHr, events, battery) is left empty. +/// reports HR, (optionally) R-R intervals, and its standard contact flags over 0x2A37; every other +/// stream (spo2, skin temp, resp, gravity, steps, ppgHr, battery) is left empty. public enum StandardHRMapping { + /// `event.kind` used for a standard BLE sensor-contact reading. The generic event table is the + /// established durable stream for additive decoded signals, so this needs no schema migration. + public static let contactEventKind = "STANDARD_HR_CONTACT" + + /// Should this reading record a contact event, given the last one recorded? + /// + /// Contact is a STATE, not a measurement: it changes when the strap goes on or comes off, a handful + /// of times a day, while the standard 0x2A37 stream arrives at ~1 Hz. Writing one row per reading + /// stored ~86,400 rows a day per device to say the same thing 86,390 times, into a local-first + /// database, for a read side that reconstructs "the value at or before ts" and therefore only ever + /// needed the changes. + /// + /// `previous == nil` records, so every session opens with its starting state and a reader never has + /// to assume one. Twin of Kotlin `StandardHrMapping.shouldRecordContact`. + public static func shouldRecordContact(previous: StandardHRContact?, + current: StandardHRContact) -> Bool { + previous != current + } + /// Build a `Streams` carrying one HR sample and zero-or-more R-R intervals, all stamped at the /// same wall-clock `ts` (unix seconds). Pure → unit-testable. - public static func samples(fromHR hr: Int, rr: [Int], at ts: Int) -> Streams { - Streams( + public static func samples(fromHR hr: Int, rr: [Int], contact: StandardHRContact? = nil, + at ts: Int) -> Streams { + let events = contact.map { [ + WhoopEvent(ts: ts, kind: contactEventKind, payload: ["contact": .string($0.rawValue)]) + ] } ?? [] + return Streams( hr: [HRSample(ts: ts, bpm: hr)], - rr: rr.map { RRInterval(ts: ts, rrMs: $0) } + rr: rr.map { RRInterval(ts: ts, rrMs: $0) }, + events: events ) } + + /// Parse a persisted `STANDARD_HR_CONTACT` `payloadJSON`. Throws on malformed JSON or a missing / + /// unknown `contact` field so a parse failure is not the same as "no contact event". + public static func contactSample(ts: Int, payloadJSON: String) throws -> StandardHRContactSample { + let payload = try JSONDecoder().decode([String: ParsedValue].self, from: Data(payloadJSON.utf8)) + guard case let .string(raw)? = payload["contact"] else { + throw ContactSampleError.missingContact + } + guard let contact = StandardHRContact(rawValue: raw) else { + throw ContactSampleError.unknownContact(raw) + } + return StandardHRContactSample(ts: ts, contact: contact) + } + + public enum ContactSampleError: Error, Equatable { + case missingContact + case unknownContact(String) + } +} + +/// One persisted standard-BLE sensor-contact reading. Legacy HR rows have no companion event and are +/// therefore absent from this stream instead of being guessed as any contact state. +public struct StandardHRContactSample: Equatable, Sendable { + public let ts: Int + public let contact: StandardHRContact + public init(ts: Int, contact: StandardHRContact) { + self.ts = ts + self.contact = contact + } } diff --git a/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift b/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift index 43bf0b7fe4..eacc9f463f 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift @@ -58,14 +58,56 @@ extension WhoopStore { return out } - /// #423 rolling retention for the raw-IMU capture table (twin of Kotlin `RAW_IMU_RETENTION_ROWS`): - /// ~1 h at 1 row/strap-second (~4 MB) hard-caps the table during a multi-day offload replay. - public static let rawImuRetentionRows = 3600 + /// Rolling retention for the v27 PPG waveform table (twin of Kotlin `PPG_WAVEFORM_RETENTION_ROWS`), + /// added for #1911. This table was previously the only UNBOUNDED blob table, and it carries by far the + /// largest PER-ROW cost of any decoded stream: ~120 B against ~30 B for a scalar row. + /// + /// It is NOT the store's fastest-growing table, and this note must not be read as saying so. v26 runs + /// only in optical windows, roughly 28,800 rows/day by #1911's own figures, where `rrInterval` banks + /// ~100,000/day and remains the higher-volume table by bytes. Capping this one bounds the worst row, + /// not the bulk of #1911's ~93 MB/day. + /// + /// **A NEWEST-N-ROWS CAP, DELIBERATELY NOT A TIME-WINDOW DROP.** #1911 proposes "dropped after the hot + /// window", justifying it as "diagnostic-only". That justification is wrong, and the migration note on + /// `ppgWaveformSample` in `Database.swift` is the authority: these rows are kept precisely so a better + /// estimator, HRV-from-PPG, or a waveform viewer can later run over the ORIGINAL samples rather than + /// the derived bpm. Deleting by wall-clock age would empty the table for exactly the user a future + /// estimator needs most — a sporadic wearer, whose v26 seconds are spread thin over months — and a + /// waveform, unlike an HR series, has no aggregate that survives it. Newest-N instead bounds the bytes + /// while ALWAYS leaving a full working set to analyse, which is the same trade `v18AuxRetentionRows` + /// below makes for the same reason. + /// + /// 604,800 = 7 × 86,400, matching the aux cap's "a week of strap-seconds" semantic and #1911's own + /// 7-day hot window. The ceiling is larger than the aux table's because the row is: ~120 B/row (a 48 B + /// packed-i16 blob for 24 samples, plus row and primary-key-index overhead) puts it at **~70 MB per + /// device**, against ~50 MB for aux. That is the bound worth quoting; the wall-clock + /// span is longer than the arithmetic suggests, because v26 only runs in optical windows. At #1911's + /// ~28,800 rows/day the cap holds about **three weeks** of typical wear, and proportionally more for a + /// sporadic wearer, which is exactly the population an age-based cutoff would have emptied. Retuning is + /// a one-constant change with no migration once a device `row_bytes` measurement lands, and RELAXING a + /// cap is always cheaper than imposing one on a user with a year of history. + public static let ppgWaveformRetentionRows = 604_800 + + /// Rows to bank before sweeping `ppgWaveformSample` again, same amortisation as + /// `v18AuxPruneEveryRows` below and the same magnitude for the same reason: the sweep walks up to + /// `ppgWaveformRetentionRows` index entries, so running it per insert batch is the cost. The table may + /// sit this many rows (plus the crossing batch) above the cap in exchange, roughly a MB against its + /// ~70 MB bound. + /// + /// WHAT THIS BUDGET DOES NOT GUARANTEE, and the reason the cap above is stated as a size rather than a + /// "hard ceiling": the counter is in-memory and per store instance, so a process restart resets it. + /// The sweep is the ONLY thing enforcing retention on this table — `Collector.prune` covers the raw + /// outbox alone, and the `*ByTs` deletes belong to `TimestampHeal`, not to retention — so a store that + /// never banks this many rows in one process lifetime never sweeps at all. It is not a concern for the + /// normal shape (the budget accumulates across every batch of a session, and one night's offload banks + /// ~28,800 rows, crossing it twice over), but a store fed only short bursts between app kills can drift + /// above the cap indefinitely. `v18AuxPruneEveryRows` below has the identical property; a sweep forced + /// once per session would close it for both, and belongs in a change that covers both. + public static let ppgWaveformPruneEveryRows = 10_000 /// v31 rolling retention for the v18 aux-slot table (twin of Kotlin `V18_AUX_RETENTION_ROWS`). /// - /// `rawImuSample` is the closest precedent — raw instrumentation banked as a blob, capped rather than - /// unbounded — and the same reasoning applies here: nothing reads these rows yet, so a cap is far + /// Raw instrumentation must be capped rather than unbounded. Nothing reads these rows yet, so a cap is far /// cheaper to RELAX later than to impose once users have a year of history. Unbounded, this table is /// the one genuinely new source of row growth in v31 (the four named channels only WIDEN rows that /// were already being written: ~14 bytes on a `gravitySample`/`skinTempSample`/`sleepStateSample` row @@ -102,27 +144,6 @@ extension WhoopStore { } } - /// #423: persist decoded 5/MG raw-IMU offload buffers (one row per strap-second, packed i16 BLOB), - /// then bound the table to the newest `retentionRows` for the device (rolling retention). Written from - /// the deep-buffer capture seam, not the normal stream path, so it inserts directly (idempotent by ts). - /// Twin of Kotlin `WhoopRepository.insertRawImu`. - public func insertRawImu(deviceId: String, rows: [(ts: Int, cols: [Int16])], retentionRows: Int) async throws { - guard !rows.isEmpty else { return } - try syncWrite { db in - let ins = try db.cachedStatement(sql: """ - INSERT INTO rawImuSample (deviceId, ts, samples) VALUES (?, ?, ?) - ON CONFLICT(deviceId, ts) DO NOTHING - """) - // Pack the raw i16 columns to the LE BLOB HERE (packImuColumns is module-internal), so the - // caller passes plain [Int16] and never needs the packer. Mirrors how `insert` packs ppgWaveform. - for r in rows { try ins.execute(arguments: [deviceId, r.ts, WhoopStore.packImuColumns(r.cols)]) } - try db.execute(sql: """ - DELETE FROM rawImuSample WHERE deviceId = ? AND ts < ( - SELECT MIN(ts) FROM (SELECT ts FROM rawImuSample WHERE deviceId = ? ORDER BY ts DESC LIMIT ?)) - """, arguments: [deviceId, deviceId, retentionRows]) - } - } - /// Idempotent upsert of decoded streams by natural key. Returns the number of rows /// ACTUALLY inserted per stream (0 for rows that already existed). /// @@ -135,7 +156,9 @@ extension WhoopStore { spo2: Int, skinTemp: Int, resp: Int, gravity: Int) { try await insert(streams, deviceId: deviceId, v18AuxRetentionRows: WhoopStore.v18AuxRetentionRows, - v18AuxPruneEveryRows: WhoopStore.v18AuxPruneEveryRows) + v18AuxPruneEveryRows: WhoopStore.v18AuxPruneEveryRows, + ppgWaveformRetentionRows: WhoopStore.ppgWaveformRetentionRows, + ppgWaveformPruneEveryRows: WhoopStore.ppgWaveformPruneEveryRows) } /// `insert(_:deviceId:)` with the v31 aux-table cap made explicit. Internal and a SEPARATE overload @@ -143,13 +166,21 @@ extension WhoopStore { /// require `insert(_:deviceId:)` exactly, and a Swift witness must match the requirement's parameter /// list — a default argument does not satisfy it. Exists so a test can prove the rolling delete with a /// small cap instead of writing 600k rows; every production caller goes through the wrapper above. + /// + /// The two v18-aux caps are required because eleven existing call sites already pass them; the two + /// ppg-waveform caps added for #1911 are DEFAULTED so those same call sites keep compiling untouched. + /// A default is fine on this overload (unlike the public entry point, per the note above) because + /// nothing witnesses it against a protocol requirement. @discardableResult func insert(_ streams: Streams, deviceId: String, v18AuxRetentionRows: Int, - v18AuxPruneEveryRows: Int) async throws + v18AuxPruneEveryRows: Int, + ppgWaveformRetentionRows: Int = WhoopStore.ppgWaveformRetentionRows, + ppgWaveformPruneEveryRows: Int = WhoopStore.ppgWaveformPruneEveryRows) async throws -> (hr: Int, rr: Int, events: Int, battery: Int, spo2: Int, skinTemp: Int, resp: Int, gravity: Int) { // Banked rows, accumulated across batches so the sweep does not run on every one. var v18Written = 0 + var ppgWaveformWritten = 0 let result: (Int, Int, Int, Int, Int, Int, Int, Int) = try syncWrite { db in var hr = 0, rr = 0, ev = 0, bat = 0 var spo2 = 0, skin = 0, resp = 0, grav = 0 @@ -284,9 +315,15 @@ extension WhoopStore { INSERT INTO stepSample (deviceId, ts, counter, activityClass) VALUES (?, ?, ?, ?) ON CONFLICT(deviceId, ts) DO NOTHING """) + var insertedSteps = 0 + var insertedStepTimestamps: [Int] = [] for s in streams.steps { try stmt.execute(arguments: [deviceId, s.ts, s.counter, s.activityClass]) + let inserted = db.changesCount + insertedSteps += inserted + if inserted > 0 { insertedStepTimestamps.append(s.ts) } } + stepDataRevision.record(deviceId: deviceId, insertedTimestamps: insertedStepTimestamps) } // Band sleep_state (#175). Persist-only, same as steps — the strap's OWN @81 high-nibble state // (0 wake/1 still/2 asleep/3 up), decoded and streamed but dropped at storage until now. Keyed by @@ -329,6 +366,7 @@ extension WhoopStore { for s in streams.ppgWaveform { try stmt.execute(arguments: [deviceId, s.ts, WhoopStore.packPpgSamples(s.samples), s.burstIndex]) + ppgWaveformWritten += 1 } } // Every remaining v18 slot (v31), one compact blob per strap-second. Persist-only, same as @@ -350,9 +388,8 @@ extension WhoopStore { return (hr, rr, ev, bat, spo2, skin, resp, grav) } - // Rolling retention (the `insertRawImu` shape, #423) but AMORTISED. The delete finds the - // Nth-newest row by rank, so it walks up to `v18AuxRetentionRows` index entries; `insertRawImu` - // keeps 3,600 so that is free, this keeps 604,800 and an offload inserts once per chunk. Swept + // Rolling retention is amortised. The delete finds the Nth-newest row by rank, so it walks up to + // `v18AuxRetentionRows` index entries. The 604,800-row cap is swept // once per `v18AuxPruneEveryRows` rows instead, which keeps newest-N-rows exactly (a time window // would not — a sporadically-worn strap's rows span far more than a week, and the census wants // that). Counter is per device because the delete is. @@ -374,6 +411,25 @@ extension WhoopStore { v18AuxRowsSincePrune[deviceId] = 0 } } + // #1911 rolling retention for the waveform blobs, amortised and best-effort on exactly the same + // terms as the aux sweep above (see `ppgWaveformRetentionRows` for why this is a newest-N cap and + // not an age-based drop). Its own counter and its own transaction: a batch routinely writes one of + // these two tables and not the other, and a failed sweep here must not fail an insert whose rows + // are already committed — leaving the budget unspent simply retries on the next batch. + if ppgWaveformWritten > 0 { + let banked = (ppgWaveformRowsSincePrune[deviceId] ?? 0) + ppgWaveformWritten + ppgWaveformRowsSincePrune[deviceId] = banked + if banked >= ppgWaveformPruneEveryRows, + (try? syncWrite { db in + try db.execute(sql: """ + DELETE FROM ppgWaveformSample WHERE deviceId = ? AND ts < ( + SELECT MIN(ts) FROM ( + SELECT ts FROM ppgWaveformSample WHERE deviceId = ? ORDER BY ts DESC LIMIT ?)) + """, arguments: [deviceId, deviceId, ppgWaveformRetentionRows]) + }) != nil { + ppgWaveformRowsSincePrune[deviceId] = 0 + } + } return result } diff --git a/Packages/WhoopStore/Sources/WhoopStore/TimestampHeal.swift b/Packages/WhoopStore/Sources/WhoopStore/TimestampHeal.swift index 8093834237..29c959cd13 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/TimestampHeal.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/TimestampHeal.swift @@ -52,12 +52,13 @@ extension WhoopStore { // legacy-rows gap rather than an ongoing one (the #547 ingest gate now rejects an implausible // ts before it is banked), which is exactly why it went unnoticed. Guarded by the same // out-of-bounds predicate as everything else — a plausible row is never touched. - let rawTables = ["hrSample", "rrInterval", "event", "battery", - "spo2Sample", "skinTempSample", "respSample", - "gravitySample", "stepSample", "ppgHrSample", - "sleepStateSample", "ppgWaveformSample", "rawImuSample", "v18AuxSample"] + // The SAME list the storage footprint reports, rather than a third copy of it. This one was + // called canonical while `storageStats` kept its own and the Apple probe a shorter one again, + // and that drift is not hypothetical: `storageStats`' comment records its old fixed sum + // omitting six of these tables. One list means a stream added later is healed AND counted AND + // named in the breakdown, or none of the three - never silently one of them. var rawDeleted = 0 - for table in rawTables { + for (_, table) in WhoopStore.rawTableKeys { try db.execute(sql: "DELETE FROM \(table) WHERE ts < ? OR ts > ?", arguments: [lo, hi]) rawDeleted += db.changesCount diff --git a/Packages/WhoopStore/Sources/WhoopStore/WhoopSerialIdentity.swift b/Packages/WhoopStore/Sources/WhoopStore/WhoopSerialIdentity.swift new file mode 100644 index 0000000000..ec221112e9 --- /dev/null +++ b/Packages/WhoopStore/Sources/WhoopStore/WhoopSerialIdentity.swift @@ -0,0 +1,69 @@ +import Foundation + +/// The stable WHOOP device id derived from the strap's own serial (#1303). +/// +/// WHOOP strap identity is otherwise a TRANSIENT CoreBluetooth UUID: a re-pair or factory reset mints a +/// fresh one, so the same physical strap forks into a second registry row and orphans its history (#1193). +/// The ring path already solved this — `DeviceRegistryStore.adoptSerialIdentity` re-points a provisional id +/// onto a serial id and migrates every device-scoped row — and this is the WHOOP half of the same idea. +/// +/// Pure and store-free so both platforms can pin the composition and, more importantly, the REFUSALS: a +/// blank or implausible serial must yield nil and leave the existing id alone. Adopting onto a junk id +/// would be worse than not adopting at all, because the migration moves every row onto it. +/// +/// Kotlin twin: `WhoopSerialIdentity`. +public enum WhoopSerialIdentity { + + /// The one place the WHOOP id namespace is spelled. `AddDeviceWizard` mints `whoop-` and + /// `DeviceRegistryStore` classifies on the same prefix; a serial id joins the same namespace so every + /// existing prefix check keeps working unchanged. + public static let idPrefix = "whoop" + + /// Shortest serial worth adopting. A 5.0/MG DIS serial is far longer; this only rejects a truncated or + /// placeholder read, which a partial GATT response can produce. + public static let minSerialLength = 6 + + /// The `whoop-` id for a strap serial, or nil when the serial cannot be trusted to identify it. + /// + /// Refuses blank/whitespace, anything under `minSerialLength`, and any serial carrying a character + /// outside `[A-Z0-9-]` after upper-casing — a DIS read that returns a descriptive string rather than a + /// serial should never become a device id. Upper-cased so the same strap read twice, in either case, + /// resolves to ONE id rather than two. + public static func adoptedId(serial: String?) -> String? { + guard let raw = serial?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } + let up = raw.uppercased() + guard up.count >= minSerialLength else { return nil } + let allowed = Set("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-") + guard up.allSatisfy({ allowed.contains($0) }) else { return nil } + return "\(idPrefix)-\(up)" + } + + /// Whether this pairing's id may be re-pointed onto a serial id at all. + /// + /// ONLY a provisional `whoop-` id qualifies. The legacy `my-whoop` seed is deliberately + /// EXCLUDED, and that exclusion is what makes this safe to ship before #1304: every existing + /// single-WHOOP install is still on that seed, ~47 code paths still read the literal `"my-whoop"` + /// directly, and `WhoopBleClient.deviceId` documents that the single-WHOOP path never reassigns it. + /// Adopting it would migrate the whole history onto `whoop-` while new samples kept being + /// written under `my-whoop` — a split history that reads as data loss. + /// + /// The legacy seed joins this path as part of #1304, once the literals no longer assume it. + public static func mayAdopt(currentId: String) -> Bool { + currentId.hasPrefix("\(idPrefix)-") + } + + /// True when `id` is already the serial id for `serial` — the steady state on every reconnect after the + /// first adoption, and the cheap early-out that keeps re-adoption from doing database work per connect. + public static func isAlreadyAdopted(id: String, serial: String?) -> Bool { + guard let target = adoptedId(serial: serial) else { return false } + return id == target + } + + /// What may be written to a SHAREABLE strap log. The serial identifies the device, so only its leading + /// characters are ever logged — the same rule `noteWhoop5VariantFromDIS` already applies to the variant + /// line. Never log `adoptedId`'s result directly. + public static func logSafe(serial: String?) -> String { + guard let raw = serial?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return "?" } + return String(raw.uppercased().prefix(3)) + "…" + } +} diff --git a/Packages/WhoopStore/Sources/WhoopStore/WhoopStore.swift b/Packages/WhoopStore/Sources/WhoopStore/WhoopStore.swift index e18eab85ec..f75e185afa 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/WhoopStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/WhoopStore.swift @@ -59,9 +59,17 @@ private actor StoreOpenGate { /// data or the query results. public actor WhoopStore { + /// PPG waveform rows banked since its retention sweep last ran, PER DEVICE, for the same reason as + /// `v18AuxRowsSincePrune` below: the sweep is per device, so a shared counter would let one strap + /// spend another's budget. Separate counter from the v18 aux one because the two caps differ and a + /// batch commonly writes one table and not the other. See `StreamStore`. + var ppgWaveformRowsSincePrune: [String: Int] = [:] + /// v18 aux rows banked since the retention sweep last ran, PER DEVICE — the sweep is per device too, /// so a shared counter would let one strap spend another's budget. See `StreamStore`. var v18AuxRowsSincePrune: [String: Int] = [:] + var stepDataRevision = StepDataRevisionIndex() + let revisionInstanceToken = UUID().uuidString let dbWriter: any DatabaseWriter /// Read-only handle to the underlying GRDB writer for the synchronous `DeviceRegistryStore`. @@ -271,3 +279,44 @@ public actor WhoopStore { } } } + +/// Compact process-local cache witness keyed by owner and UTC day. Cycle windows move with `now`, so +/// using their exact end timestamp as a cache key would invalidate an otherwise unchanged active cycle +/// every second. Keeping revisions per UTC day makes the signature stable until a row in that day lands. +/// Its process-lifetime size is proportional to owners × UTC days that receive new step rows. +struct StepDataRevisionIndex { + private static let utcDaySeconds = 86_400 + private var nextRevision = 0 + private var byOwnerDay: [OwnerDay: Int] = [:] + + private struct OwnerDay: Hashable { + let owner: String + let day: Int + } + + mutating func record(deviceId: String, insertedTimestamps: [Int]) { + guard !insertedTimestamps.isEmpty else { return } + nextRevision += 1 + for timestamp in insertedTimestamps { + byOwnerDay[OwnerDay(owner: deviceId, day: timestamp.floorDividing(by: Self.utcDaySeconds))] + = nextRevision + } + } + + func signature(deviceId: String, from: Int, to: Int) -> String { + guard to > from else { return "" } + let first = from.floorDividing(by: Self.utcDaySeconds) + let last = (to - 1).floorDividing(by: Self.utcDaySeconds) + return (first...last).map { day in + "\(day):\(byOwnerDay[OwnerDay(owner: deviceId, day: day), default: 0])" + }.joined(separator: ",") + } +} + +private extension Int { + func floorDividing(by divisor: Int) -> Int { + let quotient = self / divisor + let remainder = self % divisor + return remainder < 0 ? quotient - 1 : quotient + } +} diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/BackupSettingsTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/BackupSettingsTests.swift index f062dead1d..ee0b4ab5b4 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/BackupSettingsTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/BackupSettingsTests.swift @@ -19,8 +19,10 @@ final class BackupSettingsTests: XCTestCase { "profile.hrMax": 191, "profile.hrZoneThresholds": "95,118,142,168,184", "units.system": "imperial", + "units.distance": "metric", "units.temperature": "celsius", "effort.scale": "whoop", + "dayCycle.mode": "sleep_onset", // #today-hosted-cards: the one layout pref carried, a JSON [String] stored under the String kind. "today.hostedCards": "[\"sleep.sleepMarks\"]", // #1361: custom journal behaviours, a newline-joined name list — the embedded newline must @@ -38,8 +40,10 @@ final class BackupSettingsTests: XCTestCase { XCTAssertEqual(back["profile.hrMax"] as? Int, 191) XCTAssertEqual(back["profile.hrZoneThresholds"] as? String, "95,118,142,168,184") XCTAssertEqual(back["units.system"] as? String, "imperial") + XCTAssertEqual(back["units.distance"] as? String, "metric") XCTAssertEqual(back["units.temperature"] as? String, "celsius") XCTAssertEqual(back["effort.scale"] as? String, "whoop") + XCTAssertEqual(back["dayCycle.mode"] as? String, "sleep_onset") XCTAssertEqual(back["today.hostedCards"] as? String, "[\"sleep.sleepMarks\"]") XCTAssertEqual(back["journal.customBehaviors"] as? String, "Cold plunge\nMagnesium") XCTAssertEqual(back.count, values.count, "Nothing extra should appear") @@ -115,12 +119,14 @@ final class BackupSettingsTests: XCTestCase { defaults.set(82.5, forKey: "profile.weightKg") defaults.set(198, forKey: "profile.hrMaxOverride") // storage key, not the canonical name defaults.set("imperial", forKey: "units.system") + defaults.set("metric", forKey: "units.distance") let snap = BackupSettings.snapshot(from: defaults) XCTAssertEqual(snap["profile.age"] as? Int, 29) XCTAssertEqual(snap["profile.weightKg"] as? Double, 82.5) XCTAssertEqual(snap["profile.hrMax"] as? Int, 198, "hrMaxOverride surfaces under the canonical key") XCTAssertEqual(snap["units.system"] as? String, "imperial") + XCTAssertEqual(snap["units.distance"] as? String, "metric") XCTAssertNil(snap["profile.heightCm"], "Never-set keys are omitted, not defaulted") XCTAssertNil(snap["profile.sex"]) } diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/DailyMetricLastVitalsDayTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/DailyMetricLastVitalsDayTests.swift index adfef47ac8..752601e119 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/DailyMetricLastVitalsDayTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/DailyMetricLastVitalsDayTests.swift @@ -151,4 +151,85 @@ final class DailyMetricLastVitalsDayTests: XCTestCase { fieldDay("2999-01-01", skinTemp: 2.1)] XCTAssertEqual(DailyMetric.lastSkinTempDay(days: days, todayKey: "2026-06-19")?.day, "2026-06-16") } + + // MARK: PER-FIELD HRV / resting HR — the OR predicate's blind spot (#1842) + + /// The reported shape: the freshest row carries respiratory ONLY, so the OR predicate selects it and its + /// nil `avgHrv` renders "—" on the card while a tile carrying a different row shows a number on the same + /// screen. Pins the BUG as well as the fix, so a future simplification back onto `lastVitalsDay` fails + /// here rather than in the field. + func testSharedPredicateSelectsARowWithNoHrv_perFieldDoesNot() { + let days = [day("2026-09-01", hrv: 35, rhr: 58), + day("2026-09-02", resp: 14.2), + day("2026-09-03")] // today, nothing yet + let shared = DailyMetric.lastVitalsDay(days: days, todayKey: "2026-09-03") + XCTAssertEqual(shared?.day, "2026-09-02", "the OR predicate takes the respiratory-only row") + XCTAssertNil(shared?.avgHrv, "...and that row has no HRV — the blank the card was rendering") + + XCTAssertEqual(DailyMetric.lastHrvDay(days: days, todayKey: "2026-09-03")?.avgHrv, 35) + XCTAssertEqual(DailyMetric.lastRestingHrDay(days: days, todayKey: "2026-09-03")?.restingHr, 58) + } + + /// Freshest wins when several prior rows carry the field. + func testPerFieldCarriesTheFreshestRowHoldingTheField() { + let days = [day("2026-09-01", hrv: 30, rhr: 62), + day("2026-09-02", hrv: 41, rhr: 55), + day("2026-09-03")] + XCTAssertEqual(DailyMetric.lastHrvDay(days: days, todayKey: "2026-09-03")?.avgHrv, 41) + XCTAssertEqual(DailyMetric.lastRestingHrDay(days: days, todayKey: "2026-09-03")?.restingHr, 55) + } + + /// Same future-clock bound as every sibling: strictly prior to today's key, so today's own still-forming + /// row and a bad-clock strap's future-dated row can never be carried as "last night's". + func testPerFieldNeverCarriesTodayOrLater() { + let days = [day("2026-09-03", hrv: 44, rhr: 50), day("2026-09-04", hrv: 45, rhr: 49)] + XCTAssertNil(DailyMetric.lastHrvDay(days: days, todayKey: "2026-09-03")) + XCTAssertNil(DailyMetric.lastRestingHrDay(days: days, todayKey: "2026-09-03")) + } + + /// Nothing anywhere stays "—": the carry must never invent a value. + func testPerFieldCarriesNothingWhenNoRowHasTheField() { + let days = [day("2026-09-01", resp: 15.0), day("2026-09-02", resp: 14.0)] + XCTAssertNil(DailyMetric.lastHrvDay(days: days, todayKey: "2026-09-03")) + XCTAssertNil(DailyMetric.lastRestingHrDay(days: days, todayKey: "2026-09-03")) + } + + + // MARK: skin-temp reading row — EITHER number, one night (#1844) + + private func skinDay(_ key: String, abs: Double? = nil, dev: Double? = nil) -> DailyMetric { + DailyMetric(day: key, totalSleepMin: nil, efficiency: nil, deepMin: nil, remMin: nil, + lightMin: nil, disturbances: nil, restingHr: nil, avgHrv: nil, recovery: nil, + strain: nil, exerciseCount: nil, spo2Pct: nil, skinTempDevC: dev, + respRateBpm: nil, skinTempC: abs) + } + + /// The calibrating night the deviation-only selector skipped: a real temperature, no deviation yet. + func testFindsANightWithOnlyAnAbsolute() { + let days = [skinDay("2026-09-01", dev: -0.3), skinDay("2026-09-02", abs: 34.1)] + XCTAssertEqual(DailyMetric.lastSkinTempReadingDay(days: days, todayKey: "2026-09-03")?.day, "2026-09-02") + // The deviation-only row reaches back past it, which is what left the card empty. + XCTAssertEqual(DailyMetric.lastSkinTempDay(days: days, todayKey: "2026-09-03")?.day, "2026-09-01") + } + + /// Both numbers come off ONE row, so an absolute is never paired with another night's deviation. + func testBothSkinNumbersComeFromOneNight() { + let days = [skinDay("2026-09-01", abs: 33.0, dev: -0.9), skinDay("2026-09-02", abs: 34.1, dev: 0.2)] + let row = DailyMetric.lastSkinTempReadingDay(days: days, todayKey: "2026-09-03") + XCTAssertEqual(row?.skinTempC, 34.1) + XCTAssertEqual(row?.skinTempDevC, 0.2) + } + + /// Same future-clock bound as every sibling carry. + func testSkinReadingRowNeverCarriesTodayOrLater() { + let days = [skinDay("2026-09-03", abs: 34.0), skinDay("2026-09-04", abs: 34.2)] + XCTAssertNil(DailyMetric.lastSkinTempReadingDay(days: days, todayKey: "2026-09-03")) + } + + /// A row with neither number is not a reading. + func testSkinRowsWithNeitherNumberAreSkipped() { + let days = [skinDay("2026-09-01", abs: 33.5), skinDay("2026-09-02")] + XCTAssertEqual(DailyMetric.lastSkinTempReadingDay(days: days, todayKey: "2026-09-03")?.day, "2026-09-01") + } + } diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/DailySkinTempAbsoluteTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/DailySkinTempAbsoluteTests.swift index 6a43ebdbdb..f6d9f884f3 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/DailySkinTempAbsoluteTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/DailySkinTempAbsoluteTests.swift @@ -70,4 +70,58 @@ final class DailySkinTempAbsoluteTests: XCTestCase { XCTAssertEqual(try XCTUnwrap(rows.first?.skinTempC), 34.4, accuracy: 0.001, "a re-score must be able to correct the stored absolute") } + + // MARK: - #1853: fill-only write (`fillSkinTempC`) + + /// The fill-only write fills a NULL `skinTempC` and touches NOTHING else — the deviation, recovery, + /// HRV, and every other scored field stay exactly as the scoring pass left them. This is the + /// non-negotiable: a single-column write that can only fill a NULL, never an upsert of a rebuilt row. + func testFillSkinTempC_fillsNullAndTouchesNothingElse() async throws { + let store = try await WhoopStore.inMemory() + // A scored night with a deviation but no absolute — the backfill's exact candidate. + let d = DailyMetric(day: "2026-08-14", totalSleepMin: 415, efficiency: 0.9, + deepMin: 88, remMin: 108, lightMin: 219, disturbances: 2, + restingHr: 50, avgHrv: 62.0, recovery: 0.06, strain: 10.5, + exerciseCount: 1, spo2Pct: 96.2, skinTempDevC: 0.52, respRateBpm: 14.7, + steps: 7_900, activeKcalEst: 2_180.0, avgSdnn: 88.4, skinTempC: nil) + try await store.upsertDailyMetrics([d], deviceId: "devA") + + let filled = try await store.fillSkinTempC(deviceId: "devA", day: "2026-08-14", skinTempC: 34.6) + XCTAssertEqual(filled, 1, "exactly one row filled") + + let rows = try await store.dailyMetrics(deviceId: "devA", from: "2026-08-14", to: "2026-08-14") + let row = try XCTUnwrap(rows.first) + XCTAssertEqual(try XCTUnwrap(row.skinTempC), 34.6, accuracy: 0.001, "the absolute was filled") + // Every other field is untouched — the fill-only write touched one column. + XCTAssertEqual(try XCTUnwrap(row.skinTempDevC), 0.52, accuracy: 0.001, "deviation untouched") + XCTAssertEqual(try XCTUnwrap(row.recovery), 0.06, accuracy: 0.001, "recovery untouched") + XCTAssertEqual(try XCTUnwrap(row.avgHrv), 62.0, accuracy: 0.001, "HRV untouched") + XCTAssertEqual(try XCTUnwrap(row.restingHr), 50, "resting HR untouched") + XCTAssertEqual(try XCTUnwrap(row.totalSleepMin), 415, accuracy: 0.001, "sleep untouched") + } + + /// The fill-only write REFUSES to overwrite a measured value. A night that already has an absolute + /// is left alone — the backfill must never clobber a value the scoring pass stored. + func testFillSkinTempC_refusesToOverwriteAnExistingAbsolute() async throws { + let store = try await WhoopStore.inMemory() + let d = DailyMetric(day: "2026-08-14", totalSleepMin: 415, efficiency: 0.9, + deepMin: 88, remMin: 108, lightMin: 219, disturbances: 2, + restingHr: 50, avgHrv: 62.0, recovery: 0.06, strain: 10.5, + exerciseCount: 1, skinTempDevC: 0.52, skinTempC: 33.9) + try await store.upsertDailyMetrics([d], deviceId: "devA") + + let filled = try await store.fillSkinTempC(deviceId: "devA", day: "2026-08-14", skinTempC: 34.6) + XCTAssertEqual(filled, 0, "an existing absolute must NOT be overwritten") + + let rows = try await store.dailyMetrics(deviceId: "devA", from: "2026-08-14", to: "2026-08-14") + XCTAssertEqual(try XCTUnwrap(rows.first?.skinTempC), 33.9, accuracy: 0.001, + "the original measured value survives") + } + + /// The fill-only write is a no-op when the row doesn't exist (no orphan rows created). + func testFillSkinTempC_noOpWhenRowDoesNotExist() async throws { + let store = try await WhoopStore.inMemory() + let filled = try await store.fillSkinTempC(deviceId: "devA", day: "2026-08-14", skinTempC: 34.6) + XCTAssertEqual(filled, 0, "no row to fill — no orphan created") + } } diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/DeepCaptureChannelsTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/DeepCaptureChannelsTests.swift index 2e5d4a9c6b..4d7f0794b6 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/DeepCaptureChannelsTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/DeepCaptureChannelsTests.swift @@ -347,7 +347,6 @@ final class DeepCaptureChannelsTests: XCTestCase { /// The shipped cap is a real bound, and documented in row terms. func testShippedRetentionConstant() { XCTAssertEqual(WhoopStore.v18AuxRetentionRows, 604_800) // 7 x 86_400 strap-seconds - XCTAssertGreaterThan(WhoopStore.v18AuxRetentionRows, WhoopStore.rawImuRetentionRows) } /// Deleting a device's data must clear the aux rows too — the same privacy rule every other diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/DeviceRegistryStoreTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/DeviceRegistryStoreTests.swift index 25dd08f32a..51ae1bfbc4 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/DeviceRegistryStoreTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/DeviceRegistryStoreTests.swift @@ -222,6 +222,52 @@ final class DeviceRegistryStoreTests: XCTestCase { XCTAssertEqual(row?.model, "Oura Ring 3") } + /// The computed sibling must travel with the pairing. + /// + /// Every strap owns a second id, `-noop`, holding the days/workouts/series the engine + /// DERIVES. It never equals `activeId`, so an exact-match re-key left it behind while the next scoring + /// pass wrote under `-noop` — stranding the computed history under an id nothing reads + /// again, which is the orphaned-history failure adoption exists to prevent. A ring has no computed + /// sibling, so the shipped Oura path could never surface this. + func testAdoptSerialCarriesTheComputedSibling() throws { + let dbq = try makeDB(); let store = DeviceRegistryStore(dbQueue: dbq) + let cbuuid = "whoop-4DD70E24", serial = "whoop-MGB1234567" + try addOura(store, cbuuid, peripheralId: "4DD70E24", status: .paired, addedAt: 100) + try store.setActive(cbuuid) + try dbq.write { db in + try db.execute(sql: "INSERT INTO hrSample (deviceId, ts, bpm) VALUES ('whoop-4DD70E24', 10, 55)") + // the DERIVED half, under the computed sibling + try db.execute(sql: "INSERT INTO hrSample (deviceId, ts, bpm) VALUES ('whoop-4DD70E24-noop', 11, 56)") + } + + XCTAssertTrue(try store.adoptSerialIdentity(from: cbuuid, to: serial)) + + XCTAssertEqual(try hrCount(dbq, serial), 1) + XCTAssertEqual(try hrCount(dbq, cbuuid), 0) + XCTAssertEqual(try hrCount(dbq, serial + "-noop"), 1, "computed rows must follow the pairing") + XCTAssertEqual(try hrCount(dbq, cbuuid + "-noop"), 0, "and must not be left behind") + } + + /// A PK clash on the COMPUTED side resolves the same way as on the real id: canonical wins, source is + /// cleared, nothing is duplicated. Worth pinning separately because the clash is reachable only after + /// a re-pair that already scored days under the serial's own computed sibling. + func testAdoptSerialMergesComputedSiblingsOnClash() throws { + let dbq = try makeDB(); let store = DeviceRegistryStore(dbQueue: dbq) + let cbuuid = "whoop-0102A826", serial = "whoop-MGB7654321" + try addOura(store, serial, peripheralId: "OLDPID", status: .paired, addedAt: 100) + try addOura(store, cbuuid, peripheralId: "0102A826", status: .paired, addedAt: 200) + try store.setActive(cbuuid) + try dbq.write { db in + try db.execute(sql: "INSERT INTO hrSample (deviceId, ts, bpm) VALUES ('whoop-MGB7654321-noop', 10, 50)") + try db.execute(sql: "INSERT INTO hrSample (deviceId, ts, bpm) VALUES ('whoop-0102A826-noop', 20, 60)") + } + + try store.adoptSerialIdentity(from: cbuuid, to: serial) + + XCTAssertEqual(try hrCount(dbq, serial + "-noop"), 2) + XCTAssertEqual(try hrCount(dbq, cbuuid + "-noop"), 0) + } + func testAdoptSerialMergesWhenSerialAlreadyExists() throws { let dbq = try makeDB(); let store = DeviceRegistryStore(dbQueue: dbq) let serial = "oura-2H3B2405003655", cbuuid2 = "oura-0102A826" diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/HypnogramCoverageTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/HypnogramCoverageTests.swift new file mode 100644 index 0000000000..c6682befe3 --- /dev/null +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/HypnogramCoverageTests.swift @@ -0,0 +1,253 @@ +import XCTest +@testable import WhoopStore + +/// `HypnogramCoverage` — the ratio that tells a well-formed-looking stage timeline from one that +/// describes only part of the night it claims. +final class HypnogramCoverageTests: XCTestCase { + + /// Segments tiling `[0, span)` in 30 s steps, `n` of them, starting at `from`. + private func segs(_ ranges: [(Int, Int, String)]) -> String { + "[" + ranges.map { "{\"start\":\($0.0),\"end\":\($0.1),\"stage\":\"\($0.2)\"}" } + .joined(separator: ",") + "]" + } + + // MARK: - the ratio itself + + func testFractionIsCoveredOverSpan() { + XCTAssertEqual(HypnogramCoverage.fraction(coveredSeconds: 300, spanSeconds: 600)!, 0.5, accuracy: 1e-12) + XCTAssertEqual(HypnogramCoverage.fraction(coveredSeconds: 600, spanSeconds: 600)!, 1.0, accuracy: 1e-12) + } + + /// Overlapping/overhanging segments would otherwise report more than a whole night. A completeness + /// gate must read that as "complete", never manufacture a failure out of malformed input. + func testFractionClampsAboveOne() { + XCTAssertEqual(HypnogramCoverage.fraction(coveredSeconds: 900, spanSeconds: 600)!, 1.0, accuracy: 1e-12) + } + + /// nil means "unknown, do not judge" — distinct from 0, which a caller comparing against + /// `minCoverage` would read as a bad night. + func testFractionNilWhenNothingToMeasure() { + XCTAssertNil(HypnogramCoverage.fraction(coveredSeconds: 300, spanSeconds: 0)) + XCTAssertNil(HypnogramCoverage.fraction(coveredSeconds: 300, spanSeconds: -1)) + XCTAssertNil(HypnogramCoverage.fraction(coveredSeconds: 0, spanSeconds: 600)) + } + + // MARK: - from a stored payload + + func testTilingTimelineCoversItsSpan() { + let json = segs([(0, 300, "light"), (300, 600, "deep")]) + XCTAssertEqual(HypnogramCoverage.fraction(stagesJSON: json, spanSeconds: 600)!, 1.0, accuracy: 1e-12) + XCTAssertFalse(HypnogramCoverage.isHoled(stagesJSON: json, spanSeconds: 600)) + } + + /// The shape this whole change exists for: a hypnogram assembled from records that arrived + /// incomplete. Many segments, all real, spanning a night they only partly describe. The measured + /// worst case was 140 minutes of segments across a 601-minute span. + func testHoledTimelineIsDetected() { + let json = segs([(0, 4200, "light"), (4200, 8400, "deep")]) // 140 min over a 601 min span + let span = 601.0 * 60.0 + let f = HypnogramCoverage.fraction(stagesJSON: json, spanSeconds: span)! + XCTAssertEqual(f, 8400.0 / span, accuracy: 1e-12) + XCTAssertLessThan(f, 0.24) + XCTAssertTrue(HypnogramCoverage.isHoled(stagesJSON: json, spanSeconds: span)) + } + + /// A hole in the MIDDLE is the real failure mode (a page that never arrived), not a short tail. + func testInteriorHoleCounts() { + let json = segs([(0, 300, "light"), (900, 1200, "deep")]) // 600 s of 1200 s + XCTAssertEqual(HypnogramCoverage.fraction(stagesJSON: json, spanSeconds: 1200)!, 0.5, accuracy: 1e-12) + } + + func testThresholdBoundaryIsInclusive() { + // exactly minCoverage is NOT holed; a hair under it is. + let atGate = segs([(0, 950, "light")]) + let underGate = segs([(0, 949, "light")]) + XCTAssertFalse(HypnogramCoverage.isHoled(stagesJSON: atGate, spanSeconds: 1000)) + XCTAssertTrue(HypnogramCoverage.isHoled(stagesJSON: underGate, spanSeconds: 1000)) + } + + // MARK: - what must NOT be judged + + /// The imported minute-dict shape carries no timestamps, so coverage is unanswerable. It must come + /// back nil (not 0), which is what keeps every WHOOP/Apple/Health-Connect import out of this gate. + func testImportedMinuteDictIsUnmeasurable() { + let dict = "{\"light\":300,\"deep\":100,\"rem\":80,\"awake\":40}" + XCTAssertNil(HypnogramCoverage.fraction(stagesJSON: dict, spanSeconds: 3600)) + XCTAssertFalse(HypnogramCoverage.isHoled(stagesJSON: dict, spanSeconds: 3600)) + } + + func testEmptyAndMalformedAreUnmeasurable() { + for payload in [nil, "", " ", "[]", "not json"] as [String?] { + XCTAssertNil(HypnogramCoverage.fraction(stagesJSON: payload, spanSeconds: 3600), + "payload \(String(describing: payload)) should be unmeasurable") + XCTAssertFalse(HypnogramCoverage.isHoled(stagesJSON: payload, spanSeconds: 3600), + "an unmeasurable payload must never read as holed") + } + } + + /// Every guard built on this fails OPEN: unknown coverage keeps the previous behaviour rather than + /// downgrading a night on no evidence. + func testSessionOverloadUsesItsOwnSpan() { + let holed = CachedSleepSession(startTs: 0, endTs: 1200, efficiency: nil, restingHr: nil, + avgHrv: nil, stagesJSON: segs([(0, 300, "light")])) + let whole = CachedSleepSession(startTs: 0, endTs: 1200, efficiency: nil, restingHr: nil, + avgHrv: nil, stagesJSON: segs([(0, 1200, "light")])) + let stageless = CachedSleepSession(startTs: 0, endTs: 1200, efficiency: nil, restingHr: nil, + avgHrv: nil, stagesJSON: nil) + XCTAssertTrue(HypnogramCoverage.isHoled(holed)) + XCTAssertFalse(HypnogramCoverage.isHoled(whole)) + XCTAssertFalse(HypnogramCoverage.isHoled(stageless)) + } + + // MARK: - shapes the two readers must agree on + + /// The four payloads on which Swift and Kotlin originally DISAGREED, found by compiling both and + /// running them rather than by reading them side by side. Swift's whole-array cast makes one + /// non-object element poison the payload; the Kotlin twin used to skip that element and measure the + /// remainder, reading 0.1 and HOLED where this side read nil and not-holed. It now bails the same + /// way. Kept here so the agreement is asserted from BOTH sides, not only in the Kotlin oracle. + func testNonObjectElementMakesThePayloadUnmeasurable() { + for json in [#"[{"start":0,"end":100,"stage":"deep"},5]"#, + #"[{"start":0,"end":100,"stage":"deep"},null]"#, + #"[{"start":0,"end":100,"stage":"deep"},"x"]"#] { + XCTAssertNil(HypnogramCoverage.fraction(stagesJSON: json, spanSeconds: 1000), + "one non-object element must make the whole payload unmeasurable") + XCTAssertFalse(HypnogramCoverage.isHoled(stagesJSON: json, spanSeconds: 1000)) + } + } + + /// A string-valued bound is SKIPPED, not parsed: `NSString` is not an `NSNumber`. The Kotlin twin + /// reached the same answer only after dropping `optDouble`, which parses `"0"` happily. + func testStringBoundsAreNotCounted() { + let json = #"[{"start":"0","end":"100","stage":"deep"}]"# + XCTAssertNil(HypnogramCoverage.fraction(stagesJSON: json, spanSeconds: 1000)) + } + + /// A bool bound DOES convert, on both sides — `JSONSerialization` bridges `true` to `NSNumber` 1. + /// Absurd in a stage payload and unreachable from any producer, pinned because it is the one case + /// where the tidy-looking alignment (reject anything that is not a number) would have been wrong: + /// under Foundation `{"start":0}` reports `is Bool == true`, so a bool exclusion would have thrown + /// away every timeline whose first segment starts at zero. + func testBoolBoundConvertsOnBothSides() { + let json = #"[{"start":true,"end":100,"stage":"deep"}]"# + XCTAssertEqual(HypnogramCoverage.fraction(stagesJSON: json, spanSeconds: 1000)!, 0.099, accuracy: 1e-12) + } + + /// SCOPE: a timestamped import is judged like any other timeline. This is the Xiaomi Band shape — + /// real `{start,end,stage}` segments whose span comes from separate bed/wake fields — and it is the + /// one importer this gate reaches. Timestamp-free imports stay exempt, asserted above. + func testTimestampedImportIsInScope() { + let json = segs([(0, 3600, "light")]) + XCTAssertEqual(HypnogramCoverage.fraction(stagesJSON: json, spanSeconds: 28800)!, 0.125, accuracy: 1e-12) + XCTAssertTrue(HypnogramCoverage.isHoled(stagesJSON: json, spanSeconds: 28800)) + } + + /// The ENGINE call site sums coverage off decoded segments, not off `stagesJSON`, so the payload + /// shape rule that exempts timestamp-free imports at the merge does not run there. What actually + /// protects them is this: a group with no timestamped stages at all covers zero, and zero cover is + /// unmeasurable rather than bad. Holds only while a group is single-sourced — a group mixing a + /// staged fragment with a minute-dict one covers part of its total span and reads as holed. + func testGroupWithNoTimestampedStagesIsUnmeasurableNotHoled() { + XCTAssertNil(HypnogramCoverage.fraction(coveredSeconds: 0, spanSeconds: 8 * 3600)) + } + + func testMixedSourceGroupReadsAsHoled() { + // 8 h fully-staged fragment + a 2 h minute-dict fragment that decodes to no segments. + let covered = 8.0 * 3600, span = 10.0 * 3600 + XCTAssertEqual(HypnogramCoverage.fraction(coveredSeconds: covered, spanSeconds: span)!, 0.8, accuracy: 1e-12) + XCTAssertLessThan(0.8, HypnogramCoverage.minCoverage) + } + + /// Health Connect's REAL payload shape. The `minute-dict` case above is the throwing + /// `{light,deep,rem,awake}` object; HC emits an ARRAY of `{stage,min}` objects, which parses + /// cleanly and reaches the segment loop. It is the live producer shape this gate claims to be + /// exempt from, so it is pinned rather than argued: every element is an object (no whole-cast + /// failure), none carries bounds (every segment skipped), cover stays 0, and zero cover is + /// unmeasurable rather than bad. + func testHealthConnectStageMinArrayIsUnmeasurable() { + let json = #"[{"stage":"light","min":300},{"stage":"deep","min":100}]"# + XCTAssertNil(HypnogramCoverage.fraction(stagesJSON: json, spanSeconds: 3600)) + XCTAssertFalse(HypnogramCoverage.isHoled(stagesJSON: json, spanSeconds: 3600)) + } + + /// A JSON null BOUND inside an otherwise well-formed object: the object is fine, so neither side + /// bails; both fail to read the bound and skip the segment. + func testNullBoundSkipsTheSegment() { + XCTAssertNil(HypnogramCoverage.fraction(stagesJSON: #"[{"start":null,"end":100,"stage":"deep"}]"#, + spanSeconds: 1000)) + } + + // MARK: - the GROUP accumulation (what a night is actually judged on) + + private func sess(_ start: Int, _ end: Int, _ stagesJSON: String?) -> CachedSleepSession { + CachedSleepSession(startTs: start, endTs: end, efficiency: nil, restingHr: nil, + avgHrv: nil, stagesJSON: stagesJSON) + } + + /// A night is not one row. Two bridged fragments that are individually 90% and 100% covered are ONE + /// night at neither figure, and the group answer is what the engine gates on — so the per-row reading + /// is the wrong question for anything presenting a night. + func testGroupFractionSumsAcrossBridgedFragments() { + let a = sess(0, 1000, segs([(0, 900, "light")])) // 90% of its own span + let b = sess(1200, 2200, segs([(1200, 2200, "deep")])) // 100% of its own span + XCTAssertEqual(HypnogramCoverage.groupFraction([a, b])!, 1900.0 / 2000.0, accuracy: 1e-12) + XCTAssertEqual(HypnogramCoverage.fraction(stagesJSON: a.stagesJSON, spanSeconds: 1000)!, + 0.9, accuracy: 1e-12) + XCTAssertFalse(HypnogramCoverage.isHoledGroup([a, b])) + XCTAssertTrue(HypnogramCoverage.isHoled(a)) // and the fragment alone still is + } + + /// The inter-fragment GAP is not in the denominator. It belongs to no fragment's `[startTs, endTs)`, + /// and it is known-awake out-of-bed time (#777/#705) rather than time we failed to observe — folding + /// it in would report a correctly-recorded biphasic night as partly missing. + func testGroupFractionExcludesTheInterFragmentGap() { + let a = sess(0, 1000, segs([(0, 1000, "light")])) + let b = sess(5000, 6000, segs([(5000, 6000, "deep")])) // 4000 s gap between them + XCTAssertEqual(HypnogramCoverage.groupFraction([a, b])!, 1.0, accuracy: 1e-12) + } + + /// `testMixedSourceGroupReadsAsHoled`'s arithmetic, now asserted through the accumulation itself + /// rather than restated as two literals: a timestamp-free fragment contributes span and no cover. + func testGroupFractionMixedSourceReadsAsHoled() { + let staged = sess(0, 8 * 3600, segs([(0, 8 * 3600, "light")])) + let minuteDict = sess(8 * 3600, 10 * 3600, #"{"light":60,"deep":30,"rem":20,"awake":10}"#) + XCTAssertEqual(HypnogramCoverage.groupFraction([staged, minuteDict])!, 0.8, accuracy: 1e-12) + XCTAssertTrue(HypnogramCoverage.isHoledGroup([staged, minuteDict])) + } + + /// All-timestamp-free: zero cover over a real span is UNKNOWN, not bad. This is what keeps the gate + /// off the WHOOP CSV / Apple / Health Connect imports at the group level too. + func testGroupFractionAllTimestampFreeIsUnmeasurable() { + let a = sess(0, 4 * 3600, #"{"light":60,"deep":30,"rem":20,"awake":10}"#) + let b = sess(4 * 3600, 8 * 3600, nil) + XCTAssertNil(HypnogramCoverage.groupFraction([a, b])) + XCTAssertFalse(HypnogramCoverage.isHoledGroup([a, b])) + XCTAssertFalse(HypnogramCoverage.isHoledGroup([])) + } + + /// The REAL night this guard's UI was built against — 2026-08-28/29 on an Oura ring: a 470.5-minute + /// window whose 96 stage segments account for 440.0 minutes. It renders as a complete night and is + /// not; at 93.5% it is the case the Sleep tab's "Partly recorded" note exists to state. Pinned with + /// the boundary night from the same 31-night run (08-26, 95.2%) so the gate is shown to separate + /// them rather than to flag everything nearby. + func testGroupFractionMatchesTheObservedHoledNight() { + let holed = sess(0, Int(470.5 * 60), segs([(0, Int(440.0 * 60), "light")])) + let f = HypnogramCoverage.groupFraction([holed])! + XCTAssertEqual(f, 440.0 / 470.5, accuracy: 1e-12) + XCTAssertEqual(Int((f * 100).rounded(.down)), 93) // the percentage the note prints + XCTAssertTrue(HypnogramCoverage.isHoledGroup([holed])) + + let boundary = sess(0, 100_000, segs([(0, 95_200, "light")])) + XCTAssertFalse(HypnogramCoverage.isHoledGroup([boundary])) + } + + /// The note floors its percentage rather than rounding it: 94.8% must never print as "95%" beside a + /// badge raised because coverage fell BELOW 95%. Pinned here because the rule is a property of the + /// gate, not of one screen, and both hosts that print it must agree. + func testPrintedPercentageNeverContradictsTheGate() { + let s = sess(0, 100_000, segs([(0, 94_800, "light")])) + let f = HypnogramCoverage.groupFraction([s])! + XCTAssertTrue(f < HypnogramCoverage.minCoverage) + XCTAssertEqual(Int((f * 100).rounded(.down)), 94) + } +} diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/MetricsCacheTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/MetricsCacheTests.swift index f6cb7d549a..bd7fb5a0fc 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/MetricsCacheTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/MetricsCacheTests.swift @@ -32,15 +32,87 @@ final class MetricsCacheTests: XCTestCase { XCTAssertEqual(rows.count, 1) XCTAssertEqual(rows[0], s) - // Re-upsert the same natural key with updated values → no duplicate, value updated. + // Re-upsert the same natural key with updated values, stages covering their (shorter) new span + // just as fully as before → no duplicate, value updated (an ordinary refresh, not a regression). let s2 = CachedSleepSession(startTs: 1000, endTs: 6000, efficiency: 0.95, - restingHr: 50, avgHrv: 70.0, stagesJSON: nil) + restingHr: 50, avgHrv: 70.0, + stagesJSON: "[{\"start\":1000,\"end\":6000,\"stage\":\"light\"}]") try await store.upsertSleepSessions([s2], deviceId: "devA") rows = try await store.sleepSessions(deviceId: "devA", from: 0, to: 100_000, limit: 100) XCTAssertEqual(rows.count, 1, "same (deviceId,startTs) must not duplicate") XCTAssertEqual(rows[0].endTs, 6000) XCTAssertEqual(rows[0].efficiency, 0.95) - XCTAssertNil(rows[0].stagesJSON) + XCTAssertEqual(rows[0].stagesJSON, s2.stagesJSON) + } + + /// A candidate whose stage timeline is LESS complete than what's already stored for the same + /// (deviceId, startTs) must be dropped whole — a device serving the same night's hypnogram twice (a + /// reconnect mid-day, a resumed drain) must never have its later, thinner decode clobber an earlier, + /// fuller one. Reproduces the exact shape of a real incident: a full-night hypnogram (96 segments, + /// fully covering its span) overwritten by a 5-segment tail slice covering only ~10% of the same + /// claimed span. + func testUpsertSleepSessionsDropsALessCompleteCandidate() async throws { + let store = try await WhoopStore.inMemory() + let full = CachedSleepSession(startTs: 1_000_000, endTs: 1_032_531, efficiency: 0.886, + restingHr: 50, avgHrv: 60, + stagesJSON: "[{\"start\":1000000,\"end\":1032531,\"stage\":\"light\"}]") + try await store.upsertSleepSessions([full], deviceId: "oura-ring") + + // Same night, same key, but the "candidate" only covers the tail ~10% of the claimed span — + // holed, per HypnogramCoverage — with a bogus efficiency recomputed off just that slice. + let holedTail = CachedSleepSession(startTs: 1_000_000, endTs: 1_032_532, efficiency: 0.99, + restingHr: 48, avgHrv: 58, + stagesJSON: "[{\"start\":1029411,\"end\":1032532,\"stage\":\"rem\"}]") + try await store.upsertSleepSessions([holedTail], deviceId: "oura-ring") + + let rows = try await store.sleepSessions(deviceId: "oura-ring", from: 0, to: 2_000_000, limit: 100) + XCTAssertEqual(rows.count, 1) + XCTAssertEqual(rows[0], full, "the fuller, previously-stored night must survive untouched") + } + + /// The mirror case: a candidate that IMPROVES on a holed stored row (fills in more of the same span) + /// must still win — the guard compares richness, it does not freeze the row forever. + func testUpsertSleepSessionsAcceptsAMoreCompleteCandidate() async throws { + let store = try await WhoopStore.inMemory() + let holed = CachedSleepSession(startTs: 1_000_000, endTs: 1_032_531, efficiency: 0.5, + restingHr: 50, avgHrv: 60, + stagesJSON: "[{\"start\":1000000,\"end\":1004000,\"stage\":\"light\"}]") + try await store.upsertSleepSessions([holed], deviceId: "oura-ring") + + let full = CachedSleepSession(startTs: 1_000_000, endTs: 1_032_531, efficiency: 0.886, + restingHr: 48, avgHrv: 58, + stagesJSON: "[{\"start\":1000000,\"end\":1032531,\"stage\":\"light\"}]") + try await store.upsertSleepSessions([full], deviceId: "oura-ring") + + let rows = try await store.sleepSessions(deviceId: "oura-ring", from: 0, to: 2_000_000, limit: 100) + XCTAssertEqual(rows.count, 1) + XCTAssertEqual(rows[0], full, "a candidate that covers more of the night must still win") + } + + /// The completeness guard must never engage for a user-edited row — `applySleepEdit`'s bounds/stages + /// are the dedicated correction path, and `upsertSleepSessions` already protects them unconditionally + /// regardless of richness. (Guards against the new check accidentally widening what "protected" means.) + func testUpsertSleepSessionsGuardNeverOverridesUserEditedProtection() async throws { + let store = try await WhoopStore.inMemory() + let full = CachedSleepSession(startTs: 1_000_000, endTs: 1_032_531, efficiency: 0.886, + restingHr: 50, avgHrv: 60, + stagesJSON: "[{\"start\":1000000,\"end\":1032531,\"stage\":\"light\"}]") + try await store.upsertSleepSessions([full], deviceId: "oura-ring") + _ = try await store.applySleepEdit(deviceId: "oura-ring", detectedStartTs: 1_000_000, + newStartTs: 1_000_000, newEndTs: 1_032_531, + stagesJSON: "[{\"start\":1000000,\"end\":1032531,\"stage\":\"deep\"}]") + + // A richer-still re-decode arrives; the user's edit must still win untouched. + let richer = CachedSleepSession(startTs: 1_000_000, endTs: 1_032_531, efficiency: 0.9, + restingHr: 47, avgHrv: 57, + stagesJSON: "[{\"start\":1000000,\"end\":1016000,\"stage\":\"light\"}," + + "{\"start\":1016000,\"end\":1032531,\"stage\":\"rem\"}]") + try await store.upsertSleepSessions([richer], deviceId: "oura-ring") + + let rows = try await store.sleepSessions(deviceId: "oura-ring", from: 0, to: 2_000_000, limit: 100) + XCTAssertEqual(rows.count, 1) + XCTAssertTrue(rows[0].userEdited) + XCTAssertEqual(rows[0].stagesJSON, "[{\"start\":1000000,\"end\":1032531,\"stage\":\"deep\"}]") } /// #345: the persisted sparse-coverage flag survives write→read, REFRESHES on a recompute (excluded diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/PpgWaveformSampleTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/PpgWaveformSampleTests.swift index d3217e97b0..59281e0c14 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/PpgWaveformSampleTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/PpgWaveformSampleTests.swift @@ -108,4 +108,135 @@ final class PpgWaveformSampleTests: XCTestCase { data.append(0xFF) XCTAssertEqual(WhoopStore.unpackPpgSamples(data), [1, 2, 3]) } + + // MARK: - #1911 rolling retention + + private func retentionStore() async throws -> WhoopStore { + let s = try await WhoopStore.inMemory() + try await s.upsertDevice(id: "dev1", mac: nil, name: nil) + return s + } + + private func waveform(_ ts: Int) -> Streams { + Streams(ppgWaveform: [PpgWaveformSample(ts: ts, samples: realSamples, burstIndex: 0)]) + } + + /// The cap keeps the NEWEST rows. This is the property the whole design rests on — an age-based drop + /// would instead empty the table for a sporadic wearer, whom a future PPG estimator needs most. + func testRetentionKeepsTheNewestRows() async throws { + let s = try await retentionStore() + for ts in 100...105 { + _ = try await s.insert(waveform(ts), deviceId: "dev1", + v18AuxRetentionRows: WhoopStore.v18AuxRetentionRows, + v18AuxPruneEveryRows: WhoopStore.v18AuxPruneEveryRows, + ppgWaveformRetentionRows: 2, ppgWaveformPruneEveryRows: 1) + } + let rows = try await s.ppgWaveformSamples(deviceId: "dev1", from: 0, to: 1_000) + XCTAssertEqual(rows.map(\.ts), [104, 105], "newest-N, not oldest-N and not everything") + } + + /// Under the sweep threshold nothing is evicted — the overshoot is deliberate amortisation. + func testRetentionDoesNotSweepUnderTheThreshold() async throws { + let s = try await retentionStore() + for ts in 100...104 { + _ = try await s.insert(waveform(ts), deviceId: "dev1", + v18AuxRetentionRows: WhoopStore.v18AuxRetentionRows, + v18AuxPruneEveryRows: WhoopStore.v18AuxPruneEveryRows, + ppgWaveformRetentionRows: 2, ppgWaveformPruneEveryRows: 50) + } + let rows = try await s.ppgWaveformSamples(deviceId: "dev1", from: 0, to: 1_000) + XCTAssertEqual(rows.count, 5, "under the threshold the sweep must not run — overshoot is the point") + } + + /// The counter resets after each sweep, so a long offload sweeps repeatedly rather than latching once. + func testRetentionCounterResetsAfterEachSweep() async throws { + let s = try await retentionStore() + for ts in 100...111 { + _ = try await s.insert(waveform(ts), deviceId: "dev1", + v18AuxRetentionRows: WhoopStore.v18AuxRetentionRows, + v18AuxPruneEveryRows: WhoopStore.v18AuxPruneEveryRows, + ppgWaveformRetentionRows: 2, ppgWaveformPruneEveryRows: 4) + } + let rows = try await s.ppgWaveformSamples(deviceId: "dev1", from: 0, to: 1_000) + XCTAssertEqual(rows.map(\.ts), [110, 111], + "a second sweep must happen; the counter cannot latch after the first") + } + + /// The budget is per device, because the delete is — one strap must not spend another's. + func testRetentionBudgetIsNotSharedBetweenDevices() async throws { + let s = try await retentionStore() + try await s.upsertDevice(id: "dev2", mac: nil, name: nil) + for ts in 100...102 { + _ = try await s.insert(waveform(ts), deviceId: "dev1", + v18AuxRetentionRows: WhoopStore.v18AuxRetentionRows, + v18AuxPruneEveryRows: WhoopStore.v18AuxPruneEveryRows, + ppgWaveformRetentionRows: 1, ppgWaveformPruneEveryRows: 4) + } + // With a SHARED counter dev2's single row crosses the threshold and sweeps; because the delete is + // scoped to dev2 it would evict nothing from dev1 while zeroing dev1's budget. + _ = try await s.insert(waveform(200), deviceId: "dev2", + v18AuxRetentionRows: WhoopStore.v18AuxRetentionRows, + v18AuxPruneEveryRows: WhoopStore.v18AuxPruneEveryRows, + ppgWaveformRetentionRows: 1, ppgWaveformPruneEveryRows: 4) + _ = try await s.insert(waveform(103), deviceId: "dev1", + v18AuxRetentionRows: WhoopStore.v18AuxRetentionRows, + v18AuxPruneEveryRows: WhoopStore.v18AuxPruneEveryRows, + ppgWaveformRetentionRows: 1, ppgWaveformPruneEveryRows: 4) + let d1 = try await s.ppgWaveformSamples(deviceId: "dev1", from: 0, to: 1_000) + XCTAssertEqual(d1.map(\.ts), [103], + "dev1's own fourth row must trigger dev1's sweep — dev2 cannot spend its budget") + } + + /// A batch with no waveform row must not sweep at all: a WHOOP 4.0 offload, or any non-v26 second, + /// never pays for the index scan and never evicts. Guards against banking the budget off other streams. + func testNoWaveformRowsMeansNoRetentionSweep() async throws { + let s = try await retentionStore() + _ = try await s.insert(waveform(100), deviceId: "dev1", + v18AuxRetentionRows: WhoopStore.v18AuxRetentionRows, + v18AuxPruneEveryRows: WhoopStore.v18AuxPruneEveryRows, + ppgWaveformRetentionRows: 5, ppgWaveformPruneEveryRows: 1) + // An HR-only batch banks nothing here, so the cap of 1 must NOT evict the waveform row above. + _ = try await s.insert(Streams(hr: [HRSample(ts: 200, bpm: 60)]), deviceId: "dev1", + v18AuxRetentionRows: WhoopStore.v18AuxRetentionRows, + v18AuxPruneEveryRows: WhoopStore.v18AuxPruneEveryRows, + ppgWaveformRetentionRows: 1, ppgWaveformPruneEveryRows: 1) + let rows = try await s.ppgWaveformSamples(deviceId: "dev1", from: 0, to: 1_000) + XCTAssertEqual(rows.map(\.ts), [100]) + } + + /// One strap's sweep must not evict ANOTHER strap's rows. Distinct from the budget test above, which + /// only proves the two devices bank separately: this pins the DELETE's own device scoping, and it is + /// the reason the older strap's rows sit BELOW the sweeping strap's cutoff. With `dev2` newer than the + /// cutoff (as in the budget test) the rows survive even a DELETE that has lost its `deviceId` filter, + /// so that arrangement cannot see the bug. Dropping the outer filter is silent cross-device data loss + /// on a multi-strap install, and nothing else in the suite catches it. + func testRetentionSweepDoesNotEvictAnotherDevicesRows() async throws { + let s = try await retentionStore() + try await s.upsertDevice(id: "dev2", mac: nil, name: nil) + for ts in [500, 501] { + _ = try await s.insert(waveform(ts), deviceId: "dev2", + v18AuxRetentionRows: WhoopStore.v18AuxRetentionRows, + v18AuxPruneEveryRows: WhoopStore.v18AuxPruneEveryRows, + ppgWaveformRetentionRows: 10, ppgWaveformPruneEveryRows: 50) + } + // dev1's rows are all NEWER than dev2's, so dev1's cutoff sits above every dev2 row. + for ts in 1_000...1_003 { + _ = try await s.insert(waveform(ts), deviceId: "dev1", + v18AuxRetentionRows: WhoopStore.v18AuxRetentionRows, + v18AuxPruneEveryRows: WhoopStore.v18AuxPruneEveryRows, + ppgWaveformRetentionRows: 1, ppgWaveformPruneEveryRows: 1) + } + let d1 = try await s.ppgWaveformSamples(deviceId: "dev1", from: 0, to: 10_000) + let d2 = try await s.ppgWaveformSamples(deviceId: "dev2", from: 0, to: 10_000) + XCTAssertEqual(d1.map(\.ts), [1_003], "dev1 is swept to its own cap") + XCTAssertEqual(d2.map(\.ts), [500, 501], + "dev2's older rows must survive dev1's sweep — the DELETE is per device") + } + + /// The production cap must stay a WEEK-SCALE newest-N row count, not an age-based drop. Pins the + /// constant so a future "just make it 7 days" edit has to confront `ppgWaveformRetentionRows`' note. + func testProductionRetentionCapIsAWeekOfStrapSeconds() { + XCTAssertEqual(WhoopStore.ppgWaveformRetentionRows, 604_800) + XCTAssertEqual(WhoopStore.ppgWaveformPruneEveryRows, 10_000) + } } diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/PruneTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/PruneTests.swift index 1ba9dcf511..97b624e0a9 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/PruneTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/PruneTests.swift @@ -1,3 +1,6 @@ +#if canImport(Compression) +// Every test here drives the zlib-backed raw outbox (pruneRaw / RawBatchMeta), which exists only +// where Compression does. Same treatment RawOutboxTests gets, for the same reason. import XCTest import WhoopProtocol @testable import WhoopStore @@ -77,4 +80,6 @@ final class PruneTests: XCTestCase { maxUnsyncedBytes: 1_000_000) XCTAssertEqual(pruned, 0) } + } +#endif diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/RawOutboxTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/RawOutboxTests.swift index 848c79a08b..0f7105cb05 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/RawOutboxTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/RawOutboxTests.swift @@ -1,3 +1,4 @@ +#if canImport(Compression) import XCTest import WhoopProtocol @testable import WhoopStore @@ -85,3 +86,4 @@ final class RawOutboxTests: XCTestCase { XCTAssertEqual(gotZeros, zeros) } } +#endif diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/ReadTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/ReadTests.swift index 5737a694cd..a1c0ab2dc0 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/ReadTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/ReadTests.swift @@ -68,6 +68,76 @@ final class ReadTests: XCTestCase { XCTAssertEqual(scoped.count, 3) } + /// Regression: motion can arrive after HR in a historical offload. The whole-pass watermark must move + /// for that gravity-only commit or the first partial (no-sleep) result becomes sticky. + func testAnalysisFingerprintMovesWhenSleepCriticalGravityArrivesAfterHr() async throws { + let store = try await seeded() + let hrOnly = try await store.analysisFingerprint() + _ = try await store.insert( + Streams(gravity: [GravitySample(ts: 400, x: 0, y: 0, z: 1)]), + deviceId: "dev1" + ) + let withGravity = try await store.analysisFingerprint() + XCTAssertNotEqual(hrOnly, withGravity) + XCTAssertTrue(withGravity.contains("|g1|")) + } + + /// #29: the per-DAY twin of the test above. `analysisFingerprint` answers "did anything change + /// anywhere"; the `analyzeRecent` reuse cache asks "did THIS night's scored input change", and keyed on + /// HR alone it could not see a channel that landed after HR — so a night scored from HR alone kept + /// being re-served with no HRV in it. + /// + /// Each stream is committed on its own here, because the failure mode IS independent commits: every one + /// of them must move the witness while `hrFingerprint` stays frozen. + func testDayStreamFingerprintMovesForEveryStreamThatLandsAfterHr() async throws { + let store = try await seeded() + let hrBefore = try await store.hrFingerprint(deviceId: "dev1", from: 0, to: 1000) + var previous = try await store.dayStreamFingerprint(deviceId: "dev1", from: 0, to: 1000) + + // Every arm of the witness gets an entry: an arm that silently reads a NEIGHBOURING table is + // valid SQL and invisible to a test that never commits its stream. + let commits: [(String, Streams)] = [ + ("ppgHr", Streams(ppgHr: [PpgHrSample(ts: 401, bpm: 62, conf: 0.9)])), + ("rr", Streams(rr: [RRInterval(ts: 400, rrMs: 810)])), + ("resp", Streams(resp: [RespSample(ts: 400, raw: 12)])), + ("spo2", Streams(spo2: [SpO2Sample(ts: 400, red: 100, ir: 200)])), + ("gravity", Streams(gravity: [GravitySample(ts: 400, x: 0, y: 0, z: 1)])), + ("steps", Streams(steps: [StepSample(ts: 400, counter: 7)])), + ("skin", Streams(skinTemp: [SkinTempSample(ts: 400, raw: 1290)])), + ("sleepState", Streams(sleepState: [SleepStateSample(ts: 400, state: 2)])), + ("event", Streams(events: [WhoopEvent(ts: 410, kind: "WRIST_OFF", payload: [:])])), + ] + for (label, commit) in commits { + _ = try await store.insert(commit, deviceId: "dev1") + let now = try await store.dayStreamFingerprint(deviceId: "dev1", from: 0, to: 1000) + XCTAssertNotEqual(previous, now, "a \(label) row landing after HR must move the day witness") + previous = now + } + + // The HR witness never moved across ANY of those commits — the reason HR alone was not enough. + let hrAfter = try await store.hrFingerprint(deviceId: "dev1", from: 0, to: 1000) + XCTAssertEqual(hrBefore.count, hrAfter.count) + XCTAssertEqual(hrBefore.maxTs, hrAfter.maxTs) + + // Reading twice with nothing committed in between must be identical, or the reuse cache would + // re-score every night on every pass and give back the drain it exists to close. + let idle = try await store.dayStreamFingerprint(deviceId: "dev1", from: 0, to: 1000) + XCTAssertEqual(previous, idle) + } + + /// The witness is scoped exactly like the reads it stands in for: another device's rows and rows + /// outside the night window must not move it, or every night would invalidate on any strap's traffic. + func testDayStreamFingerprintIsDeviceAndWindowScoped() async throws { + let store = try await seeded() + let base = try await store.dayStreamFingerprint(deviceId: "dev1", from: 0, to: 1000) + _ = try await store.insert(Streams(rr: [RRInterval(ts: 400, rrMs: 810)]), deviceId: "other") + let afterOtherDevice = try await store.dayStreamFingerprint(deviceId: "dev1", from: 0, to: 1000) + XCTAssertEqual(base, afterOtherDevice, "another device's R-R is not this owner's night") + _ = try await store.insert(Streams(rr: [RRInterval(ts: 5000, rrMs: 810)]), deviceId: "dev1") + let afterOutsideWindow = try await store.dayStreamFingerprint(deviceId: "dev1", from: 0, to: 1000) + XCTAssertEqual(base, afterOutsideWindow, "an R-R beat outside the window is not this night's input") + } + func testHrBucketsAveragePerBucketOrderedAndDeviceScoped() async throws { let store = try await seeded() // 200s buckets over dev1's ts 100/200/300 (bpm 60/61/62): @@ -275,20 +345,127 @@ final class ReadTests: XCTestCase { ppgWaveform: [PpgWaveformSample(ts: 400, samples: [1, 2, 3])], v18Aux: [V18AuxSample(ts: 400, slotValues: [1, 2])]), deviceId: "dev1") - _ = try await store.insertRawImu(deviceId: "dev1", - rows: [(ts: 400, cols: [Int16(1), Int16(2)])], - retentionRows: 1000) + // The raw outbox is Compression-backed and absent off Darwin; the DECODED half of this count is + // platform-neutral and still worth asserting there, so only the raw seeding is gated. +#if canImport(Compression) try await store.enqueueRawBatch( RawBatchMeta(batchId: "b1", deviceId: "dev1", clockRef: ClockRef(device: 0, wall: 0), capturedAt: 1, startTs: 0, endTs: 0, frameCount: 1, byteSize: 4), frames: [[0xAA, 0x00, 0x01, 0x02]]) +#endif let stats = try await store.storageStats() // dev1: 3 hr + 2 rr + 1 event + 1 battery + 1 spo2 + 1 skinTemp + 1 resp + 1 gravity - // + 1 step + 1 sleepState + 1 ppgHr + 1 ppgWaveform + 1 v18Aux + 1 rawImu = 17 - // other: 1 hr = 1 → 18 decoded rows across all 14 raw tables. - XCTAssertEqual(stats.decodedRows, 18) + // + 1 step + 1 sleepState + 1 ppgHr + 1 ppgWaveform + 1 v18Aux = 16 + // other: 1 hr = 1 → 17 decoded rows. + XCTAssertEqual(stats.decodedRows, 17) +#if canImport(Compression) XCTAssertEqual(stats.rawBatches, 1) XCTAssertEqual(stats.rawBytes, 4) +#endif + } + + /// #1911: the footprint must name EVERY accumulating table, including the four the Apple probe used + /// to omit — `ppgHr`, `sleepState`, `ppgWaveform`, `v18Aux`. Those are the ones a row-count model + /// misprices worst (`ppgWaveformSample` is the only blob table), so leaving them out made the + /// footprint unable to attribute the bytes it was collected to explain. + /// + /// Pinned against the Android key set verbatim: a maintainer comparing meta.json across platforms is + /// comparing the same map, and a table added to one side without the other shows up here. + func testStorageRowCountsNamesEveryAccumulatingTable() async throws { + let store = try await WhoopStore.inMemory() + let counts = try await store.storageRowCounts() + let expected = ["hr", "rr", "events", "battery", "spo2", "skinTemp", "resp", "gravity", + "steps", "ppgHr", "sleepState", "ppgWaveform", "v18Aux"] + XCTAssertEqual(Set(counts.keys), Set(expected), + "key set must match Android's WhoopRepository.storageRowCounts exactly") + for k in expected { + XCTAssertEqual(counts[k], 0, "\(k) starts empty in a fresh store") + } + } + + /// The total and the breakdown are derived from ONE list, so they cannot disagree about which tables + /// exist. Pinned because they used to be two hand-maintained lists, and the older one had already + /// drifted once — the comment on `storageStats` records it omitting six tables. + func testStorageStatsTotalAgreesWithTheBreakdown() async throws { + let store = try await seeded() + let counts = try await store.storageRowCounts() + let stats = try await store.storageStats() + XCTAssertEqual(stats.decodedRows, counts.values.reduce(0, +), + "the summed total must equal the per-table breakdown") } + + + /// #1911: the byte estimate must MEASURE the blob rather than assume a fixed row width. + /// `ppgWaveformSample` is the only table here whose row size varies, and the one a per-second row + /// model misprices worst — a footprint that treats every row as fixed-width answers the wrong question + /// about exactly the table the question is usually about. + /// + /// Pinned by GROWING the blob rather than by comparing tables. My first attempt asserted that a blob + /// row estimates larger than an all-numeric row, which is false on a fixture: with a three-sample + /// blob `ppgWaveformSample` is three columns to `hrSample`'s four, so it estimates SMALLER. The blob + /// dominates in production (~48 bytes per v26 strap-second), not in a seed — so the invariant worth + /// pinning is that the blob's length reaches the estimate at all, which comparing two blob sizes + /// establishes and comparing two tables does not. + func testStorageByteEstimatesGrowWithBlobLength() async throws { + func estimate(sampleCount: Int) async throws -> Int { + let store = try await WhoopStore.inMemory() + let samples = [Int](repeating: 7, count: sampleCount) + _ = try await store.insert(Streams(ppgWaveform: [PpgWaveformSample(ts: 400, samples: samples)]), + deviceId: "dev1") + return try await store.storageByteEstimates()["ppgWaveform"] ?? 0 + } + let small = try await estimate(sampleCount: 4) // ~8 bytes packed i16 + let large = try await estimate(sampleCount: 200) // ~400 bytes packed i16 + XCTAssertGreaterThan(small, 0, "the blob table must be estimated, not skipped") + XCTAssertGreaterThan(large, small + 300, + "a 400-byte blob must estimate far above an 8-byte one, or the blob is not measured") + } + + /// An empty table is omitted rather than reported as zero bytes, matching the row counts: absent means + /// "nothing to attribute here", which is a different claim from a measured zero. + func testStorageByteEstimatesOmitEmptyTables() async throws { + let store = try await WhoopStore.inMemory() + let bytes = try await store.storageByteEstimates() + XCTAssertTrue(bytes.isEmpty, "a fresh store has no rows, so nothing to estimate") + } + + + /// Passing known counts must not change the answer — it only skips a second `COUNT(*)` pass. Pinned + /// because the whole point of the parameter is that it is an optimisation, and an optimisation that + /// quietly changes the number it optimises is worse than the scan it saves. + func testStorageByteEstimatesAreUnchangedByPassingKnownCounts() async throws { + let store = try await seeded() + let counts = try await store.storageRowCounts() + let computed = try await store.storageByteEstimates() + let reused = try await store.storageByteEstimates(rowCounts: counts) + XCTAssertEqual(computed, reused) + } + + /// The raw-outbox read must agree with the aggregate it was split out of, or the probe's rawBytes + /// silently changed meaning when it stopped counting thirteen decoded tables to get one number. + /// + /// They share one implementation now rather than being two copies this test compares — `syncRead` is + /// `dbWriter.read` and nesting one inside another deadlocks, so a plain `db`-taking helper is the only + /// way to share it. The assertion stays as the guard on that wiring. + func testRawOutboxStatsMatchTheAggregate() async throws { + let store = try await seeded() + let stats = try await store.storageStats() + let outbox = try await store.rawOutboxStats() + XCTAssertEqual(outbox.batches, stats.rawBatches) + XCTAssertEqual(outbox.bytes, stats.rawBytes) + } + + + /// A negative sample limit must not become an unbounded scan. SQLite reads `LIMIT -1` as "no limit", + /// so an unchecked value would full-scan every table — the exact cost the sampling exists to avoid, + /// reached by passing a number that looks like it would sample less rather than more. + func testStorageByteEstimatesClampANegativeSampleLimit() async throws { + let store = try await seeded() + let normal = try await store.storageByteEstimates() + let negative = try await store.storageByteEstimates(sampleRows: -1) + XCTAssertEqual(negative.keys.sorted(), normal.keys.sorted()) + for (k, v) in negative { XCTAssertGreaterThan(v, 0, "\(k) must still estimate") } + } + } diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json b/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json index 4cb35172f1..743485063f 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json @@ -1,6 +1,6 @@ { "_readme": "SHARED Room<->GRDB SCHEMA ORACLE (#775). Two byte-identical copies: Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json and android/app/src/test/resources/schema_oracle.json. SchemaOracleTests.swift compares GRDB's PRAGMA table_info/index_list against it; SchemaOracleTest.kt compares Room's exported schema JSON against it. `columns` is the iOS/GRDB shape in GRDB column order (macOS is the reference implementation); every way Android differs is spelled out in an `android` / `iosAbsent` / `androidColumnOrder` override naming a key in `divergenceReasons`. Adding a column, reordering one, or changing a type/nullability on one platform only fails both suites until it is either fixed or written down here.", - "roomVersion": 34, + "roomVersion": 37, "grdbMigrations": [ "v1", "v2", @@ -41,7 +41,10 @@ "v37-daily-avg-sdnn", "v38-apple-step-hour", "v39-ppg-burst-index", - "v40-daily-skin-temp-absolute" + "v40-daily-skin-temp-absolute", + "v41-drop-raw-imu-sample", + "v42-daily-sleep-hr-only", + "v43-coach-messages" ], "divergenceReasons": { "android-orphan-synced-column": "REAL COLUMN DRIFT: `synced` exists on Android only. GRDB's v10 note is explicit that stepSample gets no `synced` column ('unused; see StreamStore'), and v12's ppgHrSample never had one either; the Room entities copied the flag from the older per-second entities. Nothing reads it on Android — the per-row upload flag belongs to an upload path that does not exist there — so it is dead width, not divergent data. Removing it needs a Room table rebuild.", @@ -215,6 +218,55 @@ "androidColumnOrderDivergence": "battery-alter-append-order", "indices": [] }, + "coachMessage": { + "platform": "both", + "columns": [ + { + "name": "id", + "affinity": "TEXT", + "notNull": false, + "default": null, + "android": { + "notNull": true + }, + "divergence": "sqlite-text-pk-nullable" + }, + { + "name": "role", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "text", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "provider", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "createdAt", + "affinity": "INTEGER", + "notNull": true, + "default": null + }, + { + "name": "orderIndex", + "affinity": "INTEGER", + "notNull": true, + "default": null + } + ], + "primaryKey": [ + "id" + ], + "indices": [] + }, "cursors": { "platform": "ios_only", "reason": "iOS offload/upload bookmarks (GRDB v2). Android keeps the equivalent bookmarks in SharedPreferences, not in Room.", @@ -371,6 +423,16 @@ "affinity": "REAL", "notNull": false, "default": null + }, + { + "name": "sleepHrOnly", + "affinity": "NUMERIC", + "notNull": false, + "default": null, + "android": { + "affinity": "INTEGER" + }, + "divergence": "grdb-boolean-affinity" } ], "primaryKey": [ @@ -1259,34 +1321,6 @@ ], "indices": [] }, - "rawImuSample": { - "platform": "both", - "columns": [ - { - "name": "deviceId", - "affinity": "TEXT", - "notNull": true, - "default": null - }, - { - "name": "ts", - "affinity": "INTEGER", - "notNull": true, - "default": null - }, - { - "name": "samples", - "affinity": "BLOB", - "notNull": true, - "default": null - } - ], - "primaryKey": [ - "deviceId", - "ts" - ], - "indices": [] - }, "respSample": { "platform": "both", "columns": [ diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/ScoreInputProvenanceStoreTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/ScoreInputProvenanceStoreTests.swift index 39d780d2a2..38499cac8a 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/ScoreInputProvenanceStoreTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/ScoreInputProvenanceStoreTests.swift @@ -127,6 +127,23 @@ final class ScoreInputProvenanceStoreTests: XCTestCase { XCTAssertEqual(estimator, Vo2MaxEstimator.nes.rawValue) } + func testMarkerRewriteSpansComputedOwnerNamespaces() async throws { + let store = try await WhoopStore.inMemory() + let day = "2026-07-24" + _ = try await store.upsertMetricSeries([.init(day: day, key: "day_cycle_onset_ts", value: 1)], + deviceId: "old-noop") + try await store.persistComputedScores( + dailyMetrics: [makeDaily(day: day, recovery: nil, strain: nil)], metricPoints: [], provenance: [], + deviceId: "active-noop", from: day, to: day, + replaceMetricKeys: ["day_cycle_onset_ts"], + additionalMetricPoints: [.init(deviceId: "old-noop", + point: .init(day: day, key: "day_cycle_onset_ts", value: 2))], + replaceMetricSourceIds: ["old-noop"]) + let old = try await store.metricSeries(deviceId: "old-noop", key: "day_cycle_onset_ts", + from: day, to: day) + XCTAssertEqual(old.map(\.value), [2]) + } + private func makeDaily(day: String, recovery: Double?, strain: Double?) -> DailyMetric { DailyMetric( day: day, diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/SleepMergeTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/SleepMergeTests.swift index 4e9320e650..0c5f4b23df 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/SleepMergeTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/SleepMergeTests.swift @@ -10,7 +10,14 @@ final class SleepMergeTests: XCTestCase { CachedSleepSession(startTs: start, endTs: end, efficiency: nil, restingHr: nil, avgHrv: nil, stagesJSON: stages) } + /// One hour of segments. Against the 8-hour spans these tests use this is a HOLED timeline — which + /// is deliberate for the presence-rule cases below (rank 1 behaves exactly as the old "has stages" + /// did against a stage-less day) and is the fixture the coverage cases contrast with `tiling`. private let someStages = #"[{"start":0,"end":3600,"stage":"deep"}]"# + /// A single segment that covers `[start, end)` exactly — a night that accounts for its own span. + private func tiling(_ start: Int, _ end: Int) -> String { + #"[{"start":\#(start),"end":\#(end),"stage":"deep"}]"# + } // Deterministic "local day" keyer for the tests (real callers pass their tz-aware keyer). private let dayKey: (CachedSleepSession) -> String = { String($0.endTs / 86_400) } @@ -78,6 +85,54 @@ final class SleepMergeTests: XCTestCase { XCTAssertEqual(merged.map(\.startTs), [0, 86_400], #""[]" and blank JSON are not stages"#) } + // Coverage rank: a stage timeline that covers only part of the span it claims (a device hypnogram + // assembled from records that arrived incomplete) outranks nothing, but not a whole night. + + /// The case the presence-only rule could not express, and the reason this change exists: a HOLED + /// import used to win purely by being an import, blanking a computed night that covers itself. + func testHoledImportYieldsToFullyCoveredComputedNight() { + let comp = session(start: 0, end: 8 * 3600, stages: tiling(0, 8 * 3600)) + let imp = session(start: 0, end: 8 * 3600, stages: someStages) // 1 h of segments over 8 h + let merged = SleepMerge.merge(imported: [imp], computed: [comp], endDay: dayKey) + XCTAssertEqual(merged.map(\.startTs), [0]) + XCTAssertEqual(merged.first?.stagesJSON, tiling(0, 8 * 3600), + "the night that covers its own span wins over a holed import") + } + + /// …but partial data still beats none. A holed import must not lose to a stage-less computed day. + func testHoledImportStillBeatsStagelessComputed() { + let comp = session(start: 0, end: 8 * 3600) // no stages + let imp = session(start: 3600, end: 8 * 3600 + 1800, stages: someStages) + let merged = SleepMerge.merge(imported: [imp], computed: [comp], endDay: dayKey) + XCTAssertEqual(merged.map(\.startTs), [3600], "some stages beat none, holed or not") + } + + /// Equal rank keeps the imported-over-computed default — the rule only fires on a strict out-rank. + func testEquallyHoledSidesKeepImportedWins() { + let comp = session(start: 0, end: 8 * 3600, stages: someStages) + let imp = session(start: 3600, end: 8 * 3600 + 1800, stages: someStages) + let merged = SleepMerge.merge(imported: [imp], computed: [comp], endDay: dayKey) + XCTAssertEqual(merged.map(\.startTs), [3600]) + } + + /// A fully-covered import is untouched by any of this. + func testFullyCoveredImportStillWins() { + let comp = session(start: 0, end: 8 * 3600, stages: tiling(0, 8 * 3600)) + let imp = session(start: 3600, end: 3600 + 8 * 3600, stages: tiling(3600, 3600 + 8 * 3600)) + let merged = SleepMerge.merge(imported: [imp], computed: [comp], endDay: dayKey) + XCTAssertEqual(merged.map(\.startTs), [3600]) + } + + /// The imported minute-dict shape carries no timestamps, so it can never be judged holed — every + /// WHOOP/Apple/Health-Connect import keeps its existing precedence. + func testImportedMinuteDictIsNeverHoled() { + let comp = session(start: 0, end: 8 * 3600, stages: tiling(0, 8 * 3600)) + let imp = session(start: 3600, end: 8 * 3600 + 1800, + stages: #"{"light":300,"deep":100,"rem":80,"awake":40}"#) + let merged = SleepMerge.merge(imported: [imp], computed: [comp], endDay: dayKey) + XCTAssertEqual(merged.map(\.startTs), [3600], "a dict-shaped import is not measurable, so it wins as before") + } + func testRichnessExceptionKeepsEverySessionOfWinningDay() { // Day has computed main night (with stages) + computed nap (no stages); import is stage-less. // The WHOLE computed day survives — #715's keep-every-session guarantee still holds. diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/SourceIdentityTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/SourceIdentityTests.swift new file mode 100644 index 0000000000..91e9872068 --- /dev/null +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/SourceIdentityTests.swift @@ -0,0 +1,78 @@ +import XCTest +@testable import WhoopStore + +/// Which device a live link's samples belong to (#1881) — the twin of the Kotlin `SourceIdentityTest`, +/// asserting the same inputs and the same answers so the two platforms cannot drift. +/// +/// The reported failure is the first case here: with an Oura ring active and a WHOOP merely paired, a +/// WHOOP connection filed 770 `stepSample` and 770 `gravitySample` rows under the ring's id. A ring has no +/// pedometer and no accelerometer, so 100% of those rows were misattributed — and because `hrSample` is +/// written by both paths under one id, contamination there cannot be separated after the fact. +final class SourceIdentityTests: XCTestCase { + + private func row(_ id: String, _ brand: String, _ peripheralId: String?) -> PairedDevice { + PairedDevice(id: id, brand: brand, model: "m", peripheralId: peripheralId, + sourceKind: .liveBLE, capabilities: [.hr], status: .paired, + addedAt: 0, lastSeenAt: 0) + } + + private lazy var ring = row("oura-abc", "Oura", "AA:BB:CC:DD:EE:01") + private lazy var strap = row("whoop-123", "WHOOP", "AA:BB:CC:DD:EE:02") + + /// The reported bug: the ring is active, the STRAP connected, so the strap's id must win. + func testStrapConnectingWhileRingActiveIsAttributedToTheStrap() { + XCTAssertEqual(SourceIdentity.resolve(address: "AA:BB:CC:DD:EE:02", + rows: [ring, strap], currentId: "oura-abc"), "whoop-123") + } + + /// The legacy single-WHOOP path, and the reason this returns nil rather than guessing: the seeded row + /// has not adopted an address yet. The coordinator adopts one on this same connect, so the NEXT connect + /// resolves — and until then the id stays exactly what it is today. + func testUnadoptedRowLeavesTheIdAlone() { + let legacy = row("my-whoop", "WHOOP", nil) + XCTAssertNil(SourceIdentity.resolve(address: "AA:BB:CC:DD:EE:02", + rows: [legacy], currentId: "my-whoop")) + } + + /// No row carries this address — an unknown strap must never claim an existing device's id. + func testUnknownAddressLeavesTheIdAlone() { + XCTAssertNil(SourceIdentity.resolve(address: "FF:FF:FF:FF:FF:FF", + rows: [ring, strap], currentId: "oura-abc")) + } + + /// The inverse of the bug. If a non-WHOOP row somehow matches, re-pointing the WHOOP path's id at it + /// would file strap samples under a ring — exactly what #1881 reports, arrived at from the other side. + func testNonWhoopMatchNeverClaimsTheStrapsSamples() { + XCTAssertNil(SourceIdentity.resolve(address: "AA:BB:CC:DD:EE:01", + rows: [ring, strap], currentId: "whoop-123")) + } + + /// Already correct — the common path — must not write. + func testIdAlreadyCorrectResolvesToNothing() { + XCTAssertNil(SourceIdentity.resolve(address: "AA:BB:CC:DD:EE:02", + rows: [ring, strap], currentId: "whoop-123")) + } + + /// Neither OS guarantees the case of a UUID string or a MAC, and old rows may carry either. + func testAddressMatchingIsCaseInsensitive() { + XCTAssertEqual(SourceIdentity.resolve(address: "aa:bb:cc:dd:ee:02", + rows: [ring, strap], currentId: "oura-abc"), "whoop-123") + } + + /// A blank address is a missing address, not a wildcard that matches a blank stored value. + func testBlankAddressLeavesTheIdAlone() { + let blank = row("whoop-blank", "WHOOP", "") + XCTAssertNil(SourceIdentity.resolve(address: "", rows: [blank], currentId: "oura-abc")) + XCTAssertNil(SourceIdentity.resolve(address: nil, rows: [blank], currentId: "oura-abc")) + // Whitespace is blank on both platforms — Kotlin's `isNullOrBlank` set the contract. + let spaces = row("whoop-spaces", "WHOOP", " ") + XCTAssertNil(SourceIdentity.resolve(address: " ", rows: [spaces], currentId: "oura-abc")) + } + + /// The legacy id is a WHOOP by id even though its brand was never guaranteed. + func testLegacyRowCountsAsWhoop() { + let legacy = row("my-whoop", "", "AA:BB:CC:DD:EE:03") + XCTAssertEqual(SourceIdentity.resolve(address: "AA:BB:CC:DD:EE:03", + rows: [legacy], currentId: "oura-abc"), "my-whoop") + } +} diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/StandardHRMappingTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/StandardHRMappingTests.swift index 018ef3218f..1777250208 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/StandardHRMappingTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/StandardHRMappingTests.swift @@ -17,6 +17,47 @@ final class StandardHRMappingTests: XCTestCase { XCTAssertTrue(s.rr.isEmpty) } + func testStandardHRContactIsMappedAsAStableEvent() throws { + let s = StandardHRMapping.samples( + fromHR: 72, + rr: [], + contact: .supportedDetected, + at: 1_750_000_000 + ) + XCTAssertEqual(s.events, [ + WhoopEvent( + ts: 1_750_000_000, + kind: StandardHRMapping.contactEventKind, + payload: ["contact": .string("supported_detected")] + ) + ]) + } + + func testLegacyMappingDoesNotFabricateContact() throws { + let s = StandardHRMapping.samples(fromHR: 72, rr: [], at: 1_750_000_000) + XCTAssertTrue(s.events.isEmpty) + } + + func testContactSurvivesInsertAndReadWhileLegacyRowsStayAbsent() async throws { + let store = try await WhoopStore.inMemory() + try await store.upsertDevice(id: "standard-strap", mac: nil, name: nil) + _ = try await store.insert( + StandardHRMapping.samples(fromHR: 72, rr: [], contact: .supportedNotDetected, at: 100), + deviceId: "standard-strap" + ) + _ = try await store.insert( + StandardHRMapping.samples(fromHR: 73, rr: [], at: 101), + deviceId: "standard-strap" + ) + + let contacts = try await store.standardHRContacts( + deviceId: "standard-strap", from: 0, to: 200, limit: 10 + ) + XCTAssertEqual(contacts, [ + StandardHRContactSample(ts: 100, contact: .supportedNotDetected) + ]) + } + func testOnlyHRandRRStreamsArePopulated() throws { // A chest strap reports nothing else — every other stream must stay empty. let s = StandardHRMapping.samples(fromHR: 88, rr: [700], at: 42) @@ -29,4 +70,45 @@ final class StandardHRMappingTests: XCTestCase { XCTAssertTrue(s.events.isEmpty) XCTAssertTrue(s.battery.isEmpty) } + + func testContactSampleParsesPayloadJSONAndDoesNotTreatParseFailureAsAbsence() throws { + let detected = try StandardHRMapping.contactSample( + ts: 100, + payloadJSON: #"{"extra":true,"contact":"supported_detected"}"# + ) + XCTAssertEqual(detected, StandardHRContactSample(ts: 100, contact: .supportedDetected)) + + XCTAssertThrowsError(try StandardHRMapping.contactSample(ts: 101, payloadJSON: "not-json")) + XCTAssertThrowsError(try StandardHRMapping.contactSample(ts: 102, payloadJSON: "{}")) + } + + /// Contact is a state, not a measurement. A ~1 Hz stream that recorded every reading wrote ~86,400 + /// rows a day per device to say the same thing, for a read side that only ever needed the changes. + /// + /// Asserted over a SEQUENCE rather than on the predicate alone: the number that matters is how many + /// rows a run of readings produces, and a predicate test would pass just as happily if the caller + /// stopped consulting it. Kotlin twin: "a run of identical readings records one row, and each change + /// records one more". + func testARunOfIdenticalReadingsRecordsOneRowAndEachChangeRecordsOneMore() { + let readings = Array(repeating: StandardHRContact.supportedDetected, count: 600) + + Array(repeating: StandardHRContact.supportedNotDetected, count: 300) + + Array(repeating: StandardHRContact.supportedDetected, count: 600) + + var previous: StandardHRContact? + var recorded = 0 + for c in readings where StandardHRMapping.shouldRecordContact(previous: previous, current: c) { + recorded += 1 + previous = c + } + // 1500 readings — a full session — become three rows: the opening state and two transitions. + XCTAssertEqual(recorded, 3) + } + + /// The first reading always records, so a session never opens with an assumed state. + func testTheFirstReadingAlwaysRecords() { + XCTAssertTrue(StandardHRMapping.shouldRecordContact(previous: nil, current: .unsupported)) + XCTAssertTrue(StandardHRMapping.shouldRecordContact(previous: nil, current: .supportedDetected)) + XCTAssertFalse(StandardHRMapping.shouldRecordContact(previous: .supportedDetected, + current: .supportedDetected)) + } } diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/StepSampleTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/StepSampleTests.swift index deb4ae3a73..e5da67ae10 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/StepSampleTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/StepSampleTests.swift @@ -32,6 +32,56 @@ final class StepSampleTests: XCTestCase { XCTAssertEqual(n2, 2) } + func testStepRevisionSignatureIsStableAsActiveWindowAdvances() async throws { + let store = try await WhoopStore.inMemory() + let day = 1_780_876_800 + _ = try await store.insert(Streams(steps: [ + StepSample(ts: day + 100, counter: 1), + ]), deviceId: "strap") + + let first = await store.stepDataRevisionSignature( + deviceId: "strap", from: day, to: day + 1_000) + let later = await store.stepDataRevisionSignature( + deviceId: "strap", from: day, to: day + 2_000) + + XCTAssertEqual(first, later) + } + + func testStepRevisionOnlyInvalidatesTouchedUtcDays() async throws { + let store = try await WhoopStore.inMemory() + let day = 1_780_876_800 + _ = try await store.insert(Streams(steps: [ + StepSample(ts: day + 100, counter: 1), + ]), deviceId: "strap") + let firstDayBefore = await store.stepDataRevisionSignature( + deviceId: "strap", from: day, to: day + 86_400) + let secondDayBefore = await store.stepDataRevisionSignature( + deviceId: "strap", from: day + 86_400, to: day + 172_800) + + _ = try await store.insert(Streams(steps: [ + StepSample(ts: day + 86_500, counter: 2), + ]), deviceId: "strap") + let firstDayAfter = await store.stepDataRevisionSignature( + deviceId: "strap", from: day, to: day + 86_400) + let secondDayAfter = await store.stepDataRevisionSignature( + deviceId: "strap", from: day + 86_400, to: day + 172_800) + + XCTAssertEqual(firstDayBefore, firstDayAfter) + XCTAssertNotEqual(secondDayBefore, secondDayAfter) + } + + func testDuplicateStepInsertDoesNotInvalidateRevision() async throws { + let store = try await WhoopStore.inMemory() + let sample = StepSample(ts: 1_780_916_150, counter: 50) + _ = try await store.insert(Streams(steps: [sample]), deviceId: "strap") + let before = await store.stepDataRevisionSignature( + deviceId: "strap", from: sample.ts - 1, to: sample.ts + 1) + _ = try await store.insert(Streams(steps: [sample]), deviceId: "strap") + let after = await store.stepDataRevisionSignature( + deviceId: "strap", from: sample.ts - 1, to: sample.ts + 1) + XCTAssertEqual(before, after) + } + /// v19 migration: the @63 activity-class column exists on stepSample (#316). func testV19AddsActivityClassColumn() async throws { let store = try await WhoopStore.inMemory() diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/WhoopSerialIdentityTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/WhoopSerialIdentityTests.swift new file mode 100644 index 0000000000..4c8b0d68ce --- /dev/null +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/WhoopSerialIdentityTests.swift @@ -0,0 +1,66 @@ +import XCTest +@testable import WhoopStore + +/// The REFUSALS matter more than the composition: adoption migrates every device-scoped row onto the id +/// this returns, so a junk serial must yield nil and leave the strap on its existing id rather than move a +/// history onto a garbage key (#1303). Kotlin twin: `WhoopSerialIdentityTest`. +final class WhoopSerialIdentityTests: XCTestCase { + + func testComposesTheSerialId() { + XCTAssertEqual(WhoopSerialIdentity.adoptedId(serial: "5AG12345678"), "whoop-5AG12345678") + } + + func testUpperCasesSoOneStrapCannotBecomeTwoIds() { + XCTAssertEqual(WhoopSerialIdentity.adoptedId(serial: "5ag12345678"), + WhoopSerialIdentity.adoptedId(serial: "5AG12345678")) + } + + func testTrimsSurroundingWhitespaceFromTheGattString() { + XCTAssertEqual(WhoopSerialIdentity.adoptedId(serial: " 5AG12345678\n"), "whoop-5AG12345678") + } + + func testRefusesBlankOrMissing() { + XCTAssertNil(WhoopSerialIdentity.adoptedId(serial: nil)) + XCTAssertNil(WhoopSerialIdentity.adoptedId(serial: "")) + XCTAssertNil(WhoopSerialIdentity.adoptedId(serial: " \n ")) + } + + func testRefusesATruncatedRead() { + // A partial GATT response must not become an id: it would collide across straps. + XCTAssertNil(WhoopSerialIdentity.adoptedId(serial: "5AG")) + } + + func testRefusesADescriptiveStringThatIsNotASerial() { + // Some peripherals answer DIS with prose. Never let that become a device id. + XCTAssertNil(WhoopSerialIdentity.adoptedId(serial: "Not Available")) + XCTAssertNil(WhoopSerialIdentity.adoptedId(serial: "serial#1234")) + } + + func testAlreadyAdoptedIsTheReconnectEarlyOut() { + XCTAssertTrue(WhoopSerialIdentity.isAlreadyAdopted(id: "whoop-5AG12345678", serial: "5AG12345678")) + XCTAssertFalse(WhoopSerialIdentity.isAlreadyAdopted(id: "whoop-ABCDEF-0123", serial: "5AG12345678")) + // An unusable serial is never "already adopted" — otherwise a junk read would silently suppress a + // later good one. + XCTAssertFalse(WhoopSerialIdentity.isAlreadyAdopted(id: "whoop-5AG12345678", serial: " ")) + } + + /// The guard that makes this safe to ship before #1304. Every existing single-WHOOP install is on the + /// legacy `my-whoop` seed, ~47 code paths read that literal directly, and `WhoopBleClient` never + /// reassigns its deviceId on the single-WHOOP path — so adopting it would migrate the history onto + /// `whoop-` while new samples kept landing under `my-whoop`. A split history reads as data loss. + func testRefusesToAdoptTheLegacySingleWhoopSeed() { + XCTAssertFalse(WhoopSerialIdentity.mayAdopt(currentId: "my-whoop")) + // A provisional pairing id IS adoptable — that is the multi-strap case this ships for. + XCTAssertTrue(WhoopSerialIdentity.mayAdopt(currentId: "whoop-6B9F2C11-0000-4000-8000-0000000000AA")) + // An already-adopted serial id stays adoptable; the equality check upstream stops the re-migration. + XCTAssertTrue(WhoopSerialIdentity.mayAdopt(currentId: "whoop-5AG12345678")) + // Another brand's id is never touched by the WHOOP path. + XCTAssertFalse(WhoopSerialIdentity.mayAdopt(currentId: "oura-2H3B2405003655")) + } + + func testLogSafeNeverLeaksTheFullSerial() { + XCTAssertEqual(WhoopSerialIdentity.logSafe(serial: "5AG12345678"), "5AG…") + XCTAssertEqual(WhoopSerialIdentity.logSafe(serial: nil), "?") + XCTAssertFalse(WhoopSerialIdentity.logSafe(serial: "5AG12345678").contains("12345678")) + } +} diff --git a/README.md b/README.md index 4226c6019a..d4749ec2f9 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ 👽 Reddit · Features · Protocol · + Raw data capture ·

@@ -95,7 +96,7 @@ Pre-built apps you can run right now: Prefer to build it yourself? See [`docs/BUILD.md`](docs/BUILD.md). -Everything runs **offline**. The only feature that ever uses the network is the optional **AI Coach**, and only with your own API key. +Everything runs **offline by default** — nothing about you leaves the device unless you switch on a feature that sends it. NOOP makes only three kinds of network request, all described in [`docs/PRIVACY_SECURITY.md`](docs/PRIVACY_SECURITY.md): the optional **AI Coach** (off until you add your own API key), a once-a-day check for a newer release, which sends nothing about you and never installs anything, and Android's default-off Experimental one-way **push** to an endpoint you own. Turn the check off in Settings → About and it makes no request at all. NOOP operates no server, account, or telemetry service. --- @@ -187,7 +188,7 @@ shared cross-platform code. | **Data Sources** | One-tap import of a WHOOP CSV export, an Apple Health export, or a **nutrition CSV** (Cronometer / MacroFactor), plus live-strap status. "Bring your history in once, then it's yours." | | **Notifications** | Configure local notifications and thresholds (`Strand/Data/NotificationSettingsStore.swift`). | | **Automations** | Turn the strap's physical inputs and live biometrics into Mac actions — all on-device (see below). | -| **Coach** | An optional **AI Coach** you can ask about your data in plain language. It's the one feature that can ever use the network: off until you add your own key — Anthropic, OpenAI, or any OpenAI-compatible endpoint including a local/self-hosted model (Ollama, LM Studio) — and it sends only a short text summary of recent metrics plus your question, never raw streams or identifiers. With a local model the conversation never leaves your machine. Available on macOS, Android, and iOS. See [`docs/PRIVACY_SECURITY.md`](docs/PRIVACY_SECURITY.md). | +| **Coach** | An optional **AI Coach** you can ask about your data in plain language. It is off until you add your own key — Anthropic, OpenAI, or any OpenAI-compatible endpoint including a local/self-hosted model (Ollama, LM Studio) — and it sends only a short text summary of recent metrics plus your question, never raw streams or identifiers. With a local model the conversation never leaves your machine. Available on macOS, Android, and iOS. See [`docs/PRIVACY_SECURITY.md`](docs/PRIVACY_SECURITY.md). | | **Settings** | Profile, preferences, **step calibration** (tune the stride/step estimate to your own walking), unit choices, the in-app **What's new** changelog, and an opt-in **Experimental** section (WHOOP 5/MG protocol probes). On **iOS**, also **Export for Shortcuts** — a HealthKit-free path that hands your metrics to Apple Health via the Shortcuts app. | There is also a **menu-bar extra** (`Strand/MenuBar/MenuBarContent.swift`) with a @@ -521,9 +522,17 @@ Every arrow stays on your machine. ## Privacy -**Offline by design.** NOOP has no server, no telemetry, and no account. Your -strap data, imports, and computed metrics live in a local SQLite database on your -device and never leave it. +**Offline by default.** NOOP has no server, no telemetry, and no account. Your strap data, imports, +and computed metrics live in a local SQLite database on your device. They leave only through an +export or optional network feature you deliberately configure, including Android's default-off +Experimental one-way push to your own endpoint; see +[`docs/PRIVACY_SECURITY.md`](docs/PRIVACY_SECURITY.md). + +The app makes two kinds of network request, neither carrying anything about you: the optional +**AI Coach** (off until you add your own key), and a once-a-day read of the latest release number so a +sideloaded install can tell you an update exists — on by default, switchable off in Settings → About, +and it never installs anything. Both are detailed in +[`docs/PRIVACY_SECURITY.md`](docs/PRIVACY_SECURITY.md). --- diff --git a/SECURITY.md b/SECURITY.md index be3eeaa13d..13a74a9559 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,18 +2,25 @@ ## Scope -NOOP is a fully offline, on-device app. It has no servers, no accounts, and no -cloud sync, so the usual web attack surface does not apply. What remains is local: +NOOP is an offline-by-default, on-device app. It operates no servers, accounts, +cloud sync, or telemetry, so the usual hosted-service attack surface does not apply. +What remains is local or explicitly user-configured: - **Bluetooth Low Energy** — the link to your WHOOP strap. - **Local SQLite database** — every reading is stored on your own device. - **File imports** — WHOOP CSV exports and Apple Health ZIP files you choose to open. -- **AI Coach (optional, off by default)** — the only feature that makes a network - request, and only with an API key you supply yourself, to the provider you choose. - -A useful security report is one that lets data leave the device when it shouldn't, +- **AI Coach (optional, off by default)** — sends a compact summary only when asked, + with credentials you supply, to the provider you choose. +- **Self-hosted push (Experimental, Android, off by default)** — sends versioned batches + one way to a user-owned endpoint after offload; it never reads records back and never + participates in strap sync. A receiver can only narrow the fixed stream registry it accepts. + See [`docs/PUSH_PROTOCOL.md`](docs/PUSH_PROTOCOL.md). +- **Oura history import (compile-time optional)** — pulls from Oura using a developer + application and grant supplied by the user; it is absent from default builds. + +A useful security report is one that lets data leave the device without the corresponding opt-in, lets a malicious strap or crafted import file corrupt the database or run code, or -otherwise breaks the offline, local-only guarantee the app makes. +otherwise breaks the offline-by-default or one-way-export guarantees the app makes. ## Reporting a vulnerability diff --git a/Strand/AI/AICoach.swift b/Strand/AI/AICoach.swift index 0677802d8d..8d91153709 100644 --- a/Strand/AI/AICoach.swift +++ b/Strand/AI/AICoach.swift @@ -156,8 +156,20 @@ final class AICoachEngine: ObservableObject { // Published state the UI binds to. @Published var messages: [ChatMessage] = [] + + /// Local day the current transcript was last written on; nil while it is empty. Drives the day + /// boundary in `send` — see `isStaleConversation`. Kotlin twin: `CoachViewModel.conversationDay`. + private var conversationDay: Int? @Published var sending = false @Published var errorText: String? + + /// #1862: a question handed over by the Today Coach launcher sheet, for `CoachView` to send on appear. + /// + /// The launcher owns no send, stream, error or consent surface of its own — duplicating those is how a + /// second chat UI drifts from the first. It collects a question and hands it here; the Coach screen, + /// which already has all of that, consumes it exactly once and clears it. Nil is the normal state, and + /// setting it performs NO network work by itself. + @Published var pendingPrompt: String? @Published var provider: AIProvider { didSet { guard provider != oldValue else { return } @@ -202,6 +214,14 @@ final class AICoachEngine: ObservableObject { didSet { UserDefaults.standard.set(includeOnDeviceSignals, forKey: Self.onDeviceSignalsKey) } } + /// K11: THIRD opt-in — send a chart image alongside the text when using Gemini's multimodal + /// API. OFF by default and gated behind `dataConsent` too. Only active when the provider is + /// Gemini (the only provider with multimodal support in the app). When on, the Coach composer + /// shows an "Attach chart" toggle; the rendered chart is sent as inline_data to Gemini. + @Published var multimodalChartEnabled: Bool { + didSet { UserDefaults.standard.set(multimodalChartEnabled, forKey: Self.multimodalChartKey) } + } + private let repo: Repository private let session: URLSession @@ -210,6 +230,7 @@ final class AICoachEngine: ObservableObject { private static let consentKey = "ai.dataConsent" private static let customConnectedKey = "ai.customConnected" private static let onDeviceSignalsKey = "ai.includeOnDeviceSignals" + private static let multimodalChartKey = "ai.multimodalChartEnabled" /// UserDefaults key holding the user's EDITED system prompt. Absent (or blank) means "use the /// built-in default". Small text key, never a secret, so plain UserDefaults is fine. Read FRESH /// per request (see `systemPrompt`) so an edit takes effect on the very next message. @@ -221,8 +242,10 @@ final class AICoachEngine: ObservableObject { static let defaultSystemPrompt = """ You are an elite, supportive recovery and performance coach with a real training methodology. \ You may be given a summary of the user's own wearable data (charge 0-100, effort 0-100, rest 0-100, \ - HRV, resting heart rate) and recent workouts. Charge is the daily recovery/readiness score, effort \ - is the daily cardiovascular load score, and rest is the nightly sleep-quality score. \ + sleep duration and its deep/REM/light breakdown, sleep efficiency, HRV, resting heart rate) and \ + recent workouts. Charge is the daily recovery/readiness score, effort is the daily cardiovascular \ + load score, and rest is the nightly sleep-quality score. A dash in the data means that value was \ + NOT MEASURED that day — say so rather than treating it as a zero. \ Coach using autoregulation: • Readiness → prescription: charge 67-100 = green light to build/push, higher effort is fine; \ 34-66 = maintain, quality over volume, keep it controlled; 0-33 = active recovery only \ @@ -277,6 +300,39 @@ final class AICoachEngine: ObservableObject { objectWillChange.send() } + /// Contextual suggestion chips for the composer, derived from today's bands via `CoachSuggestions`. + /// Reads only on-device `repo.days`; pure, byte-identical to the Android twin. Returns the stable + /// generic fallback when there is no usable data for today. + var suggestions: [String] { CoachSuggestions.suggestions(for: repo.days.last, recent: repo.days) } + + /// K7: Follow-up suggestion chips shown after each assistant reply. These are generic + /// conversational follow-ups (not data-derived) so the user can dig deeper without typing. + /// Byte-identical to the Android twin's `followUpSuggestions`. + static let followUpSuggestions: [String] = [ + "Tell me more about that", + "What should I do next?", + "How does today compare to this week?", + "Give me a specific action plan", + ] + + /// K12: Rough token estimate for the next send, based on the current draft + context size. + /// Uses the standard ~4 chars/token heuristic. This is an estimate only — actual token counts + /// vary by tokenizer. Returns nil when the engine isn't configured (no context to estimate). + func estimatedTokens(forDraft draft: String) -> Int? { + guard isConfigured else { return nil } + // Estimate the context size: system prompt + data context (rough — we don't build the + // full context here to avoid a DB read on every keystroke). Use the last known context + // size or a reasonable default. + let systemPromptTokens = systemPrompt.count / 4 + // The data context is typically ~2000-4000 chars depending on the user's data. + // Use a conservative estimate of 3000 chars (750 tokens) when consent is on. + let contextTokens = dataConsent ? 750 : 50 + // History tokens: sum of all message texts in the windowed history. + let historyTokens = windowedMessages().reduce(0) { $0 + $1.text.count / 4 } + let draftTokens = draft.count / 4 + return systemPromptTokens + contextTokens + historyTokens + draftTokens + } + /// Used in place of the metrics context when the user has NOT granted data access. private let noConsentNote = """ NOTE: The user has not granted access to their biometric data. Coach generally and encourage \ @@ -312,6 +368,7 @@ final class AICoachEngine: ObservableObject { self.customAuthHeader = AIProvider.customAuthHeader self.customConnected = UserDefaults.standard.bool(forKey: Self.customConnectedKey) self.includeOnDeviceSignals = UserDefaults.standard.bool(forKey: Self.onDeviceSignalsKey) + self.multimodalChartEnabled = UserDefaults.standard.bool(forKey: Self.multimodalChartKey) } // MARK: Key management @@ -361,6 +418,12 @@ final class AICoachEngine: ObservableObject { func disconnect() { AIKeyStore.clear() customConnected = false + // Retire the transcript with the connection. Kotlin has done this since the method existed + // (CoachViewModel.disconnect) and this side never did, so returning to the setup screen on Apple + // left the whole conversation sitting behind it — including whatever the user had told a coach + // they were in the middle of disconnecting from. + messages = [] + conversationDay = nil objectWillChange.send() } @@ -384,6 +447,11 @@ final class AICoachEngine: ObservableObject { /// Forget the stored key. func clearKey() { AIKeyStore.clear() + // Same reasoning as `disconnect`: clearing the key returns the user to the setup screen, and + // Kotlin empties the transcript when it does. Leaving it meant a "clear my key" on Apple removed + // the credential and kept the conversation. + messages = [] + conversationDay = nil objectWillChange.send() } @@ -474,6 +542,77 @@ final class AICoachEngine: ObservableObject { } } + // MARK: - K2: persisted conversation history + + /// Guards `loadPersistedMessagesIfNeeded()` so it only ever runs once per app launch, even if the + /// Coach screen's `.task` re-fires (e.g. a tab re-select). + private var didLoadPersistedMessages = false + + /// Load the conversation persisted by a PRIOR launch (PRD-K2), so relaunching doesn't lose it. + /// Called from the Coach screen's `.task` (mirroring `startBriefIfNeeded`) rather than `init`, + /// which is synchronous and runs for every screen the app builds, not just Coach. Best-effort: a + /// store failure just leaves the transcript empty, matching pre-K2 behaviour — never crashes. + func loadPersistedMessagesIfNeeded() async { + guard !didLoadPersistedMessages else { return } + didLoadPersistedMessages = true + guard messages.isEmpty, let store = await repo.storeHandle() else { return } + guard let rows = try? await store.coachMessages(), !rows.isEmpty else { return } + messages = rows + .sorted { $0.orderIndex < $1.orderIndex } + .map { ChatMessage(id: UUID(uuidString: $0.id) ?? UUID(), + role: ChatMessage.Role(rawValue: $0.role) ?? .user, + text: $0.text) } + } + + /// Replace the ENTIRE persisted conversation with the current in-memory `messages`. Called once + /// per completed send/brief (not per streamed chunk) so a streamed reply's several in-place text + /// mutations don't hammer the store. Fire-and-forget; a store failure never blocks the UI — the + /// in-memory transcript (what the user sees) is unaffected either way. + private func persistMessages() { + let snapshot = messages + let providerId = provider.rawValue + Task { + guard let store = await repo.storeHandle() else { return } + let rows = snapshot.enumerated().map { index, m in + CoachMessageRow(id: m.id.uuidString, role: m.role.rawValue, text: m.text, + provider: providerId, createdAt: Int(Date().timeIntervalSince1970), + orderIndex: index) + } + try? await store.replaceCoachMessages(rows) + } + } + + /// The Coach toolbar's "Clear conversation" action: wipes both the in-memory transcript and the + /// persisted table. Fire-and-forget on the store side; the in-memory clear is immediate. + func clearConversation() { + messages = [] + droppedSummary = nil // K13: reset the summary cache on clear + droppedSummaryKey = [] + Task { try? await repo.storeHandle()?.clearCoachMessages() } + } + + /// K5: surface a brief generated by the SCHEDULED morning-brief notification as the first Coach + /// message, with no network call — called once when the app opens via a tap on that notification. + /// No-op if a conversation already exists, so it never duplicates into an active chat. + func surfaceScheduledBrief(_ text: String) { + guard messages.isEmpty else { return } + appendMessage(ChatMessage(role: .assistant, text: "Today's brief\n\n" + text)) + persistMessages() + } + + /// K5: append an explicitly-generated brief (the Coach settings "Generate now" button) as a new + /// assistant message, unconditionally — unlike `surfaceScheduledBrief`, this always appends so a + /// mid-conversation tap still shows the fresh brief. + func appendGeneratedBrief(_ text: String) { + appendMessage(ChatMessage(role: .assistant, text: "Today's brief\n\n" + text)) + persistMessages() + } + + /// K11: An optional chart image (base64-encoded PNG) to send with the next user message. + /// Set by the composer's "Attach chart" toggle when multimodal is enabled and the provider + /// is Gemini. Consumed (cleared) on the next send. nil when no image is attached. + @Published var pendingChartImage: String? + /// Send a question: append it, build the metrics context, call the chosen provider with the /// system prompt + context + running history, parse the reply, append it. Never throws/crashes; /// failures land in `errorText`. @@ -482,59 +621,189 @@ final class AICoachEngine: ObservableObject { guard !trimmed.isEmpty else { errorText = AICoachError.emptyQuestion.errorDescription; return } guard let key = resolvedKey else { errorText = AICoachError.noKey.errorDescription; return } + // A transcript from an earlier local day is retired before the new turn is appended (#1542, + // Kotlin twin merged first). `messages` outlives a night — the engine is held for the app's + // lifetime — so without this the coach answers TODAY's question inside YESTERDAY's + // conversation. The DATA was never stale: buildFullContext() re-reads on every send. It is the + // assistant's own earlier turns stating yesterday's figures, and the model staying consistent + // with them, which reads as "the coach only talks about my imported data" after a night of + // fresh strap data. + // + // Placed AFTER the guards on purpose: a send that never happens must not wipe a transcript. + let today = Self.localEpochDay() + if Self.isStaleConversation(lastEpochDay: conversationDay, todayEpochDay: today) { + messages = [] + } + conversationDay = today + errorText = nil appendMessage(ChatMessage(role: .user, text: trimmed)) sending = true - defer { sending = false } + // K2: persist once the turn is fully settled (success, mid-stream error, or empty-stream + // removal) — not per streamed chunk, so a long reply doesn't hammer the store. + defer { sending = false; persistMessages() } // Build the data context once and prepend it to the FIRST user turn we send. We send the // full running history so follow-ups stay coherent; the context only needs to ride the // earliest user message. // Include the user's data ONLY with explicit consent; otherwise send a note instead of numbers. let context = dataConsent ? await buildFullContext() : noConsentNote - let wire = wireMessages(context: context) + // K13: if the conversation overflows the sliding window, summarize the dropped middle so + // the model retains context continuity. Best-effort; failure degrades to the old gap. + await summarizeDroppedMiddleIfNeeded(key: key) + var wire = wireMessages(context: context) + + // K11: If a chart image is pending and the provider is Gemini, attach it to the last + // user turn as inline_data. Non-Gemini providers can't accept images, so the image is + // silently dropped (the text question still goes through). Cleared after consumption. + let imageBase64 = pendingChartImage + pendingChartImage = nil + + // K1: Stream the reply. Append a placeholder assistant message, then mutate its text as + // chunks arrive by replacing the last element in `messages`. The transcript re-renders on + // each update (SwiftUI binds to `messages`). On error mid-stream, keep the partial text and + // append a "(stream interrupted)" marker — never a crash. + let placeholder = ChatMessage(role: .assistant, text: "") + appendMessage(placeholder) + var accumulated = "" do { - let reply = try await callProvider(key: key, messages: wire) - let clean = reply.trimmingCharacters(in: .whitespacesAndNewlines) - appendMessage(ChatMessage(role: .assistant, text: clean.isEmpty ? "(no reply)" : clean)) + try await streamProvider(key: key, messages: wire, inlineImage: imageBase64) { delta in + accumulated += delta + // Replace the last message's text with the accumulated stream so far. + if let lastIdx = self.messages.indices.last, + self.messages[lastIdx].role == .assistant { + self.messages[lastIdx] = ChatMessage( + id: placeholder.id, role: .assistant, text: accumulated + ) + } + } + // Finalize: trim whitespace. If the stream produced nothing, show "(no reply)". + let clean = accumulated.trimmingCharacters(in: .whitespacesAndNewlines) + if let lastIdx = messages.indices.last, messages[lastIdx].role == .assistant { + messages[lastIdx] = ChatMessage( + id: placeholder.id, role: .assistant, + text: clean.isEmpty ? "(no reply)" : clean + ) + } } catch let e as AICoachError { + // Mid-stream error: keep the partial text + an interrupted marker (PRD K1 acceptance). + let partial = accumulated.trimmingCharacters(in: .whitespacesAndNewlines) + if !partial.isEmpty, let lastIdx = messages.indices.last, messages[lastIdx].role == .assistant { + messages[lastIdx] = ChatMessage( + id: placeholder.id, role: .assistant, + text: partial + "\n\n*(stream interrupted)*" + ) + } else if let lastIdx = messages.indices.last, messages[lastIdx].role == .assistant { + // No text received at all — remove the empty placeholder. + messages.remove(at: lastIdx) + } errorText = e.errorDescription } catch { + let partial = accumulated.trimmingCharacters(in: .whitespacesAndNewlines) + if !partial.isEmpty, let lastIdx = messages.indices.last, messages[lastIdx].role == .assistant { + messages[lastIdx] = ChatMessage( + id: placeholder.id, role: .assistant, + text: partial + "\n\n*(stream interrupted)*" + ) + } else if let lastIdx = messages.indices.last, messages[lastIdx].role == .assistant { + messages.remove(at: lastIdx) + } errorText = AICoachError.network(error.localizedDescription).errorDescription } } /// Proactively generate "Today's brief" the first time the Coach opens, readiness + a training /// prescription + one recovery tip, without the user typing. Requires a key + data consent. + /// K1: streams the brief the same way `send` does. func startBriefIfNeeded() async { guard isConfigured, dataConsent, messages.isEmpty, !sending else { return } guard let key = resolvedKey else { return } errorText = nil sending = true - defer { sending = false } + defer { sending = false; persistMessages() } let context = await buildFullContext() - let instruction = """ - Based on the data above, give me TODAY'S coaching brief in three short parts: \ - (1) my readiness in one line, citing charge, HRV and rest; \ - (2) exactly what training to do today and what to avoid; \ - (3) one specific thing to improve my charge. Be punchy and motivating. - """ - let wire: [(role: ChatMessage.Role, content: String)] = [(.user, context + "\n\n---\n\n" + instruction)] + let wire: [(role: ChatMessage.Role, content: String)] = + [(.user, context + "\n\n---\n\n" + Self.briefInstruction)] + + let prefix = "Today's brief\n\n" + let placeholder = ChatMessage(role: .assistant, text: prefix) + appendMessage(placeholder) + var accumulated = "" + do { - let reply = try await callProvider(key: key, messages: wire) - let clean = reply.trimmingCharacters(in: .whitespacesAndNewlines) - if !clean.isEmpty { - appendMessage(ChatMessage(role: .assistant, text: "Today's brief\n\n" + clean)) + try await streamProvider(key: key, messages: wire) { delta in + accumulated += delta + if let lastIdx = self.messages.indices.last, + self.messages[lastIdx].role == .assistant { + self.messages[lastIdx] = ChatMessage( + id: placeholder.id, role: .assistant, text: prefix + accumulated + ) + } + } + let clean = accumulated.trimmingCharacters(in: .whitespacesAndNewlines) + if clean.isEmpty { + if let lastIdx = messages.indices.last, messages[lastIdx].role == .assistant { + messages.remove(at: lastIdx) + } + } else if let lastIdx = messages.indices.last, messages[lastIdx].role == .assistant { + messages[lastIdx] = ChatMessage(id: placeholder.id, role: .assistant, text: prefix + clean) } } catch let e as AICoachError { + let partial = accumulated.trimmingCharacters(in: .whitespacesAndNewlines) + if partial.isEmpty { + if let lastIdx = messages.indices.last, messages[lastIdx].role == .assistant { + messages.remove(at: lastIdx) + } + } else if let lastIdx = messages.indices.last, messages[lastIdx].role == .assistant { + messages[lastIdx] = ChatMessage( + id: placeholder.id, role: .assistant, + text: prefix + partial + "\n\n*(stream interrupted)*" + ) + } errorText = e.errorDescription } catch { + let partial = accumulated.trimmingCharacters(in: .whitespacesAndNewlines) + if partial.isEmpty { + if let lastIdx = messages.indices.last, messages[lastIdx].role == .assistant { + messages.remove(at: lastIdx) + } + } else if let lastIdx = messages.indices.last, messages[lastIdx].role == .assistant { + messages[lastIdx] = ChatMessage( + id: placeholder.id, role: .assistant, + text: prefix + partial + "\n\n*(stream interrupted)*" + ) + } errorText = AICoachError.network(error.localizedDescription).errorDescription } } + /// K5: The brief instruction shared by the interactive `startBriefIfNeeded()` (streamed into the + /// chat) and the headless `generateBrief()` below (used by the scheduled morning-brief notification). + /// Kept in one place so the two paths never drift. + private static let briefInstruction = """ + Based on the data above, give me TODAY'S coaching brief in three short parts: \ + (1) my readiness in one line, citing charge, HRV and rest; \ + (2) exactly what training to do today and what to avoid; \ + (3) one specific thing to improve my charge. Be punchy and motivating. + """ + + /// K5: Generate today's coaching brief WITHOUT touching the visible chat transcript. Used by the + /// scheduled morning-brief notification (`CoachBriefScheduler`), which can run with no Coach screen + /// open and must never append to (or duplicate into) `messages`. Non-streaming (a background/BGTask + /// context has no UI to stream into). Returns nil when not configured/consented, on any network + /// failure, or when the reply is empty — the caller treats nil as "brief unavailable"; never throws. + func generateBrief() async -> String? { + guard isConfigured, dataConsent, let key = resolvedKey else { return nil } + let context = await buildFullContext() + let wire: [(role: ChatMessage.Role, content: String)] = + [(.user, context + "\n\n---\n\n" + Self.briefInstruction)] + guard let reply = try? await callProvider(key: key, messages: wire) else { return nil } + let clean = reply.trimmingCharacters(in: .whitespacesAndNewlines) + return clean.isEmpty ? nil : clean + } + /// Full data context = the metrics summary + recent workouts (+ an OPT-IN on-device-signals summary /// when the second consent is on). Used when the user has granted data access. func buildFullContext() async -> String { @@ -553,7 +822,7 @@ final class AICoachEngine: ObservableObject { } /// One derived stress line for the coach context: the Baevsky Stress Index over TODAY's R-R, read - /// via the store exactly as `StressView` does (`storeHandle()` → `rrIntervals(deviceId:from:to:)`), + /// via the same device-aware repository R-R union as `StressView`, /// then summarised to a single number with `StressIndex.stressIndex(rr:)`. Returns nil when the /// store is unavailable or there are too few clean beats (the histogram needs >= 20), so the line is /// simply absent, never a fabricated value. Summary-only: the raw R-R never leaves the device. @@ -561,9 +830,7 @@ final class AICoachEngine: ObservableObject { let cal = Calendar.current let from = Int(cal.startOfDay(for: Date()).timeIntervalSince1970) let to = Int(Date().timeIntervalSince1970) - guard let store = await repo.storeHandle() else { return nil } - let rr = (try? await store.rrIntervals( - deviceId: repo.deviceId, from: from, to: to, limit: 200_000)) ?? [] + let rr = await repo.rrIntervals(from: from, to: to, limit: 200_000) guard let si = StressIndex.stressIndex(rr: rr) else { return nil } return Self.stressIndexSummary(si: si) } @@ -583,13 +850,20 @@ final class AICoachEngine: ObservableObject { // 1. Strongest behaviour→outcome associations (EffectRanker over the journal × Charge). let entries = await repo.journalEntries() + // Yes days and NO days, kept apart. A day with no journal row for the question lands in + // neither, so an unanswered day is never counted as a No (BehaviorInsights.effect). var byBehaviour: [String: Set] = [:] - for e in entries where e.answeredYes { byBehaviour[e.question, default: []].insert(e.day) } + var controls: [String: Set] = [:] + for e in entries { + if e.answeredYes { byBehaviour[e.question, default: []].insert(e.day) } + else { controls[e.question, default: []].insert(e.day) } + } if !byBehaviour.isEmpty { let outcomeByDay = Dictionary( repo.days.compactMap { d in d.recovery.map { (d.day, $0) } }, uniquingKeysWith: { _, last in last }) - let ranked = EffectRanker.rank(behaviors: byBehaviour, outcomeByDay: outcomeByDay, outcome: "Charge") + let ranked = EffectRanker.rank(behaviors: byBehaviour, controls: controls, + outcomeByDay: outcomeByDay, outcome: "Charge") .filter { $0.effect.significant } .prefix(3) if !ranked.isEmpty { @@ -633,18 +907,118 @@ final class AICoachEngine: ObservableObject { ) } + /// K1: Dispatch to the user's chosen provider client's streaming method. The default + /// `AIProviderClient.stream` falls back to `send` + a single delta, so providers without + /// streaming still work. K11: when an inline image is present, dispatches to + /// `streamWithImage` instead (Gemini overrides it; others ignore the image). + private func streamProvider(key: String, + messages: [(role: ChatMessage.Role, content: String)], + inlineImage: String? = nil, + onDelta: (String) -> Void) async throws { + try await provider.client.streamWithImage( + key: key, + model: model, + systemPrompt: systemPrompt, + messages: messages, + inlineImage: inlineImage, + session: session, + onDelta: onDelta + ) + } + /// Sliding window over the chat: the FIRST user turn (it carries the metrics context) plus the most /// recent `maxHistoryMessages`, dropping the middle. Sending the whole growing history crowds out the /// reply on small-context local servers (Ollama defaults to a 2048-token window, the Custom /// provider's main use case) and balloons token cost/latency on cloud providers. (parity with Android) + /// True when a transcript last written on `lastEpochDay` should be retired before a question asked + /// on `todayEpochDay` — i.e. the conversation crossed into a new local day. + /// + /// STRICTLY forward (`>`, never `!=`): a clock that moves BACKWARDS — the user flying west, a + /// timezone change, an NTP correction — must not wipe a conversation they are in the middle of. + /// Only real elapsed days retire a transcript. A nil `lastEpochDay` (nothing sent yet) is never + /// stale. Kotlin twin: `CoachViewModel.isStaleConversation`. + /// + /// `nonisolated` because it is a pure function of its arguments. AICoachEngine is @MainActor, so + /// without this the rule inherits that isolation and cannot be called from a synchronous test — + /// which is exactly how the first attempt at this twin failed to compile. Isolating a function + /// that touches no state buys nothing and costs its testability. + nonisolated static func isStaleConversation(lastEpochDay: Int?, todayEpochDay: Int) -> Bool { + guard let lastEpochDay else { return false } + return todayEpochDay > lastEpochDay + } + + /// Days since the epoch in the LOCAL calendar — the twin of Kotlin's `LocalDate.now().toEpochDay()`. + /// + /// Counted with calendar day arithmetic from `startOfDay`, not by dividing an interval by 86,400: + /// a day is not always 86,400 seconds (DST), and the rule only needs a value that increments + /// exactly once per local midnight and orders correctly. Injectable so the tests never depend on + /// the machine's clock or zone. + nonisolated static func localEpochDay(_ date: Date = Date(), calendar: Calendar = .current) -> Int { + let start = calendar.startOfDay(for: date) + let epoch = Date(timeIntervalSince1970: 0) + return calendar.dateComponents([.day], from: epoch, to: start).day ?? 0 + } + + /// + /// K13: when the middle is dropped, a one-line summary of the dropped turns is prepended to the + /// first user turn so the model retains context continuity (instead of seeing a gap). The summary + /// is generated via the same provider, with a short prompt; on failure it degrades to the old + /// behaviour (no summary, just the windowed set). private static let maxHistoryMessages = 10 + /// K13: the cached summary of the dropped middle, regenerated when the dropped set changes. + private var droppedSummary: String? + private var droppedSummaryKey: [String] = [] + private func windowedMessages() -> [ChatMessage] { guard messages.count > Self.maxHistoryMessages + 1, let firstUser = messages.firstIndex(where: { $0.role == .user }) else { return messages } let recentStart = messages.count - Self.maxHistoryMessages // If the first user turn already falls inside the recent window, that window covers it. if firstUser >= recentStart { return Array(messages.suffix(Self.maxHistoryMessages)) } - return [messages[firstUser]] + Array(messages[recentStart...]) + // K13: inject the summary of the dropped middle by prepending it to the first user turn, + // so the model sees continuity instead of a gap. We don't use a separate system message + // because the Role enum only has .user/.assistant (providers map those to API roles). + var windowed = [messages[firstUser]] + if let summary = droppedSummary { + let first = windowed[0] + windowed[0] = ChatMessage(id: first.id, role: first.role, text: "\(summary)\n\n---\n\n\(first.text)") + } + windowed.append(contentsOf: messages[recentStart...]) + return windowed + } + + /// K13: When the conversation overflows the sliding window, summarize the dropped middle turns + /// into a single system message. Called before each send when the window would drop messages. + /// Best-effort: on any failure, leaves `droppedSummary` nil (the old gap behaviour). + private func summarizeDroppedMiddleIfNeeded(key: String) async { + guard messages.count > Self.maxHistoryMessages + 1, + let firstUser = messages.firstIndex(where: { $0.role == .user }) else { return } + let recentStart = messages.count - Self.maxHistoryMessages + guard firstUser < recentStart else { return } + + // The dropped middle is messages[firstUser+1 ..< recentStart]. Cache on its identity so we + // don't re-summarize the same set on every send. + let dropped = Array(messages[(firstUser + 1).. String { let rows = await repo.workoutRows(days: 30) // newest first guard !rows.isEmpty else { return "Recent workouts: none recorded in the last 30 days." } + let bodySystem = UnitSystem( + rawValue: UserDefaults.standard.string(forKey: UnitPrefs.systemKey) ?? "") ?? .metric + let distanceSystem = UnitPrefs.resolveDistance( + system: bodySystem, + override: UserDefaults.standard.string(forKey: UnitPrefs.distanceSystemKey) ?? "") var lines = ["Recent workouts (newest first):"] for w in rows.prefix(limit) { var parts = [" \(dateString(w.startTs)) \(w.sport)"] @@ -719,7 +1099,9 @@ final class AICoachEngine: ObservableObject { if let s = w.strain { parts.append("effort \(String(format: "%.1f", s))") } if let hr = w.avgHr { parts.append("avg HR \(hr)") } if let kcal = w.energyKcal { parts.append("\(Int(kcal.rounded())) kcal") } - if let dist = w.distanceM { parts.append("\(String(format: "%.1f", dist / 1000)) km") } + if let dist = w.distanceM { + parts.append(UnitFormatter.distanceFromMeters(dist, system: distanceSystem)) + } lines.append(parts.joined(separator: ", ")) } return lines.joined(separator: "\n") @@ -727,16 +1109,57 @@ final class AICoachEngine: ObservableObject { // MARK: Formatting helpers - private func dayLine(_ d: DailyMetric) -> String { + /// `internal`, not private, so `AICoachSleepContextTests` can assert the emitted line directly. + /// Swift's `buildContext()` takes no arguments (it reads the repo), unlike the Kotlin twin which is + /// handed the day list — so without this the formatter has no seam and the Swift half of a change + /// with fifteen Kotlin tests would ship untested. + func dayLine(_ d: DailyMetric) -> String { var parts: [String] = [d.day + ":"] parts.append("charge " + (d.recovery.map { "\(Int($0.rounded()))" } ?? "—")) parts.append("effort " + (d.strain.map { String(format: "%.1f", $0) } ?? "—")) parts.append("rest " + (d.totalSleepMin.map { String(format: "%.1fh", $0 / 60) } ?? "—")) + // The stage breakdown and efficiency, which the coach could not see at all: a user asked why it + // said it had no access to sleep stages, and it was answering honestly — `rest 7.8h` was every + // word it got about a night. These four sit on the SAME DailyMetric the line already reads, so + // nothing new is plumbed; they were simply never included. (#124 widened this context once + // before, for the same reason.) + // + // Always emitted, "—" when absent, like every other field here. A night with no staging then + // says so rather than going quiet, which matters more than line length: the alternative — only + // appending stages when present — gives the model a schema that changes shape between days and + // invites it to read a missing field as a zero. + parts.append("deep " + hoursOrDash(d.deepMin)) + parts.append("REM " + hoursOrDash(d.remMin)) + parts.append("light " + hoursOrDash(d.lightMin)) + parts.append("eff " + efficiencyPercentOrDash(d.efficiency)) parts.append("HRV " + (d.avgHrv.map { "\(Int($0.rounded()))ms" } ?? "—")) parts.append("RHR " + (d.restingHr.map { "\($0)bpm" } ?? "—")) return parts.joined(separator: ", ") } + /// Minutes as "1.4h", or "—" when the night has no value. Matches the `rest` field's format so a + /// stage total and the total it is part of read on the same scale. + private func hoursOrDash(_ minutes: Double?) -> String { + minutes.map { String(format: "%.1fh", $0 / 60) } ?? "—" + } + + /// Efficiency as a percentage, NORMALISING the stored value first. + /// + /// `DailyMetric.efficiency` is not reliably a 0–1 fraction: it "arrives as % on some import paths", + /// which `SleepView` and `StagesCard` each guard against inline with this same `> 1.5` test. A bare + /// `* 100` would therefore hand the coach "eff 9400%" for an imported night — and a model given a + /// nonsense number reasons about it confidently rather than ignoring it. + /// + /// 1.5 rather than 1.0 because a genuine fraction can exceed 1.0 only by floating-point noise, while + /// a genuine percentage is 30–100 and nowhere near the threshold. Android's two copies of this guard + /// split at 1.0 instead, which is a pre-existing divergence and not this change's to settle. + func efficiencyPercentOrDash(_ raw: Double?) -> String { + guard var e = raw, e > 0 else { return "—" } + if e > 1.5 { e /= 100 } + guard e > 0, e <= 1 else { return "—" } + return "\(Int((e * 100).rounded()))%" + } + private func avgOne(_ xs: [Double]) -> String { guard !xs.isEmpty else { return "—" } return String(format: "%.1f", xs.reduce(0, +) / Double(xs.count)) diff --git a/Strand/AI/AIProvider.swift b/Strand/AI/AIProvider.swift index 1aeeffa652..c2306ffe58 100644 --- a/Strand/AI/AIProvider.swift +++ b/Strand/AI/AIProvider.swift @@ -1,4 +1,5 @@ import Foundation +import StrandAnalytics // MARK: - Provider enum @@ -227,6 +228,65 @@ protocol AIProviderClient { /// Fetch the provider's live model list and return plain model ids. func fetchModels(key: String, session: URLSession) async throws -> [String] + + /// Stream a chat turn, calling `onDelta` for each text chunk as it arrives. The concatenated + /// deltas must equal the text that `send` would return for the same inputs (byte-parity with + /// the non-streamed path). The default implementation falls back to `send` + a single delta, + /// so providers without streaming still work. K1. + func stream( + key: String, + model: String, + systemPrompt: String, + messages: [(role: ChatMessage.Role, content: String)], + session: URLSession, + onDelta: (String) -> Void + ) async throws + + /// K11: Stream a chat turn with an optional inline image (base64 PNG). Only Gemini implements + /// this; the default implementation ignores the image and calls `stream`. This keeps the + /// multimodal path opt-in without changing every provider's `stream` signature. + func streamWithImage( + key: String, + model: String, + systemPrompt: String, + messages: [(role: ChatMessage.Role, content: String)], + inlineImage: String?, + session: URLSession, + onDelta: (String) -> Void + ) async throws +} + +extension AIProviderClient { + /// K11: Default — ignore the image, delegate to `stream`. Providers without multimodal support + /// (OpenAI, Anthropic, Custom) use this; only Gemini overrides it. + func streamWithImage( + key: String, + model: String, + systemPrompt: String, + messages: [(role: ChatMessage.Role, content: String)], + inlineImage: String?, + session: URLSession, + onDelta: (String) -> Void + ) async throws { + try await stream(key: key, model: model, systemPrompt: systemPrompt, + messages: messages, session: session, onDelta: onDelta) + } +} + +extension AIProviderClient { + /// Default: fall back to the non-streaming `send` and emit the full reply as one delta. + func stream( + key: String, + model: String, + systemPrompt: String, + messages: [(role: ChatMessage.Role, content: String)], + session: URLSession, + onDelta: (String) -> Void + ) async throws { + let reply = try await send(key: key, model: model, systemPrompt: systemPrompt, + messages: messages, session: session) + onDelta(reply) + } } // MARK: - Shared HTTP helpers @@ -283,3 +343,45 @@ func emptyReplyError(_ json: [String: Any]) -> AICoachError { return .emptyReply("The provider returned an empty reply. If you set a custom model by hand, check " + "that the model name is one the provider actually offers.") } + +// MARK: - SSE streaming helper (K1) + +/// Execute a streaming SSE request, map HTTP status codes to `AICoachError`, and call `onLine` +/// for each `data:` payload line (prefix stripped, blank/comment/non-data lines filtered). The +/// caller's `onLine` closure uses the pure `SseDeltas` functions to extract the per-provider text +/// delta. Throws on HTTP errors (same mapping as `performRequest`). K1. +func performStreamingRequest( + _ req: URLRequest, + session: URLSession, + onLine: (String) -> Void +) async throws { + let bytes: (URLSession.AsyncBytes, URLResponse) + do { + bytes = try await session.bytes(for: req) + } catch { + throw AICoachError.network(error.localizedDescription) + } + + guard let http = bytes.1 as? HTTPURLResponse else { + throw AICoachError.network("no HTTP response") + } + + switch http.statusCode { + case 200...299: + // Read line-by-line from the SSE byte stream. `URLSession.AsyncBytes` splits on \n. + for try await line in bytes.0.lines { + if let payload = SseDeltas.dataPayload(fromLine: line) { + onLine(payload) + } + } + case 401, 403: + throw AICoachError.badKey + case 429: + throw AICoachError.rateLimited + default: + // For non-200, the body is a (non-streaming) error JSON — collect it and surface the message. + var body = "" + for try await line in bytes.0.lines { body += line } + throw AICoachError.server(http.statusCode, providerErrorMessage(from: Data(body.utf8))) + } +} diff --git a/Strand/AI/Providers/Anthropic.swift b/Strand/AI/Providers/Anthropic.swift index 1e5071ef24..2b80518b32 100644 --- a/Strand/AI/Providers/Anthropic.swift +++ b/Strand/AI/Providers/Anthropic.swift @@ -1,4 +1,5 @@ import Foundation +import StrandAnalytics struct AnthropicClient: AIProviderClient { @@ -39,6 +40,42 @@ struct AnthropicClient: AIProviderClient { return text } + /// K1: Stream via `stream: true`. Anthropic SSE uses typed events; we extract `content_block_delta` + /// with `text_delta` via `SseDeltas.anthropicDelta`. Byte-parity pin in + /// `SseDeltasTests.anthropicReassembleMatchesFullReply`. + func stream( + key: String, + model: String, + systemPrompt: String, + messages: [(role: ChatMessage.Role, content: String)], + session: URLSession, + onDelta: (String) -> Void + ) async throws { + var wire: [[String: Any]] = [] + for m in messages { wire.append(["role": m.role.rawValue, "content": m.content]) } + + let body: [String: Any] = [ + "model": model, + "max_tokens": 4096, + "system": systemPrompt, + "messages": wire, + "stream": true + ] + + var req = URLRequest(url: AIProvider.anthropic.endpoint) + req.httpMethod = "POST" + req.setValue(key, forHTTPHeaderField: "x-api-key") + req.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + req.setValue("application/json", forHTTPHeaderField: "content-type") + req.httpBody = try JSONSerialization.data(withJSONObject: body) + + try await performStreamingRequest(req, session: session) { payload in + if let delta = SseDeltas.anthropicDelta(payload) { + onDelta(delta) + } + } + } + func fetchModels(key: String, session: URLSession) async throws -> [String] { var req = URLRequest(url: AIProvider.anthropic.modelsEndpoint) req.httpMethod = "GET" diff --git a/Strand/AI/Providers/Custom.swift b/Strand/AI/Providers/Custom.swift index 5c1a8d8627..a52b33c229 100644 --- a/Strand/AI/Providers/Custom.swift +++ b/Strand/AI/Providers/Custom.swift @@ -1,4 +1,5 @@ import Foundation +import StrandAnalytics /// A generic OpenAI-compatible provider pointed at a user-set base URL — e.g. a local LLM server such /// as Ollama, LM Studio or llama.cpp (`http://localhost:11434/v1`), or any self-hosted gateway. Speaks @@ -31,6 +32,42 @@ struct CustomClient: AIProviderClient { } } + /// K1: Stream via `stream: true` (most local OpenAI-compatible servers support it). Same body + /// as `send`'s standard-params path, with `stream: true`. SSE parsing via `SseDeltas.openAiDelta`. + /// The modern-params retry on 400 is NOT streamed (rare path; falls back to `send`'s retry). + func stream( + key: String, + model: String, + systemPrompt: String, + messages: [(role: ChatMessage.Role, content: String)], + session: URLSession, + onDelta: (String) -> Void + ) async throws { + try AIProvider.guardCustomBaseURL() // #321: reject a public cleartext Custom URL before egress + var wire: [[String: Any]] = [["role": "system", "content": systemPrompt]] + for m in messages { wire.append(["role": m.role.rawValue, "content": m.content]) } + + let body: [String: Any] = [ + "model": model, + "messages": wire, + "temperature": 0.6, + "max_tokens": 4096, + "stream": true + ] + + var req = URLRequest(url: AIProvider.custom.endpoint) + req.httpMethod = "POST" + AIProvider.applyCustomAuthHeader(key, to: &req) + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try JSONSerialization.data(withJSONObject: body) + + try await performStreamingRequest(req, session: session) { payload in + if let delta = SseDeltas.openAiDelta(payload) { + onDelta(delta) + } + } + } + /// Appended to a reply when the server stopped early because it ran out of context window. /// Local OpenAI-compatible servers (notably Ollama, which defaults to a 2048-token window and /// IGNORES `num_ctx` on the `/v1` endpoint) truncate silently — no error, the text just stops diff --git a/Strand/AI/Providers/Gemini.swift b/Strand/AI/Providers/Gemini.swift index 3c7f9a54bb..ccf4829b22 100644 --- a/Strand/AI/Providers/Gemini.swift +++ b/Strand/AI/Providers/Gemini.swift @@ -1,4 +1,5 @@ import Foundation +import StrandAnalytics struct GeminiClient: AIProviderClient { @@ -9,14 +10,7 @@ struct GeminiClient: AIProviderClient { messages: [(role: ChatMessage.Role, content: String)], session: URLSession ) async throws -> String { - var contents: [[String: Any]] = [] - for m in messages { - contents.append([ - "role": m.role == .assistant ? "model" : "user", - "parts": [["text": m.content]] - ]) - } - + let contents = GeminiClient.buildContents(from: messages) let body: [String: Any] = [ "system_instruction": ["parts": [["text": systemPrompt]]], "contents": contents, @@ -52,8 +46,104 @@ struct GeminiClient: AIProviderClient { return text } - func fetchModels(key: String, session: URLSession) async throws -> [String] { - var req = URLRequest(url: AIProvider.gemini.modelsEndpoint) + /// K11: Build Gemini "contents" array from wire messages. Extracted so the multimodal path + /// (which appends an `inline_data` image part to the last user turn) can reuse it. + static func buildContents(from messages: [(role: ChatMessage.Role, content: String)]) -> [[String: Any]] { + var contents: [[String: Any]] = [] + for m in messages { + contents.append([ + "role": m.role == .assistant ? "model" : "user", + "parts": [["text": m.content]] + ]) + } + return contents + } + + /// K11: Append a base64-encoded image to the last user turn's parts (Gemini multimodal). + /// The image is sent as an `inline_data` part alongside the existing text part. + static func appendImage(_ base64: String, mimeType: String, to contents: inout [[String: Any]]) { + guard let lastUserIdx = contents.lastIndex(where: { ($0["role"] as? String) == "user" }) else { return } + var parts = contents[lastUserIdx]["parts"] as? [[String: Any]] ?? [] + parts.append(["inline_data": ["mime_type": mimeType, "data": base64]]) + contents[lastUserIdx]["parts"] = parts + } + + /// K1: Stream via `:streamGenerateContent?alt=sse`. Same body as `send`, different endpoint + + /// SSE parsing. The concatenated deltas are byte-identical to `send`'s return (parity pin in + /// `SseDeltasTests.geminiReassembleMatchesFullReply`). + func stream( + key: String, + model: String, + systemPrompt: String, + messages: [(role: ChatMessage.Role, content: String)], + session: URLSession, + onDelta: (String) -> Void + ) async throws { + let contents = GeminiClient.buildContents(from: messages) + + let body: [String: Any] = [ + "system_instruction": ["parts": [["text": systemPrompt]]], + "contents": contents, + "generationConfig": ["temperature": 0.6, "maxOutputTokens": 4096] + ] + + // :streamGenerateContent?alt=sse — SSE line-per-chunk. Built via URL(string:) to avoid + // appendingPathComponent percent-encoding the ":" (same note as `send`). + guard let url = URL(string: "\(AIProvider.gemini.endpoint.absoluteString)/\(model):streamGenerateContent?alt=sse") else { + throw AICoachError.network("invalid model id") + } + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.setValue(key, forHTTPHeaderField: "x-goog-api-key") + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try JSONSerialization.data(withJSONObject: body) + + try await performStreamingRequest(req, session: session) { payload in + if let delta = SseDeltas.geminiDelta(payload) { + onDelta(delta) + } + } + } + + /// K11: Stream with an optional inline image (base64 PNG). Appends the image as + /// `inline_data` to the last user turn's parts, then streams as normal. + func streamWithImage( + key: String, + model: String, + systemPrompt: String, + messages: [(role: ChatMessage.Role, content: String)], + inlineImage: String?, + session: URLSession, + onDelta: (String) -> Void + ) async throws { + var contents = GeminiClient.buildContents(from: messages) + if let image = inlineImage { + GeminiClient.appendImage(image, mimeType: "image/png", to: &contents) + } + + let body: [String: Any] = [ + "system_instruction": ["parts": [["text": systemPrompt]]], + "contents": contents, + "generationConfig": ["temperature": 0.6, "maxOutputTokens": 4096] + ] + + guard let url = URL(string: "\(AIProvider.gemini.endpoint.absoluteString)/\(model):streamGenerateContent?alt=sse") else { + throw AICoachError.network("invalid model id") + } + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.setValue(key, forHTTPHeaderField: "x-goog-api-key") + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try JSONSerialization.data(withJSONObject: body) + + try await performStreamingRequest(req, session: session) { payload in + if let delta = SseDeltas.geminiDelta(payload) { + onDelta(delta) + } + } + } + + func fetchModels(key: String, session: URLSession) async throws -> [String] { var req = URLRequest(url: AIProvider.gemini.modelsEndpoint) req.httpMethod = "GET" req.setValue(key, forHTTPHeaderField: "x-goog-api-key") diff --git a/Strand/AI/Providers/OpenAI.swift b/Strand/AI/Providers/OpenAI.swift index 30f9a295e7..78f06f5658 100644 --- a/Strand/AI/Providers/OpenAI.swift +++ b/Strand/AI/Providers/OpenAI.swift @@ -1,4 +1,5 @@ import Foundation +import StrandAnalytics struct OpenAIClient: AIProviderClient { @@ -26,6 +27,37 @@ struct OpenAIClient: AIProviderClient { } } + /// K1: Stream via `stream: true`. Same body as `send`, with `stream: true` added. SSE parsing + /// via `SseDeltas.openAiDelta`. The modern-params retry on 400 is NOT streamed (rare path; + /// falls back to `send`'s retry). Byte-parity pin in `SseDeltasTests.openAiReassembleMatchesFullReply`. + func stream( + key: String, + model: String, + systemPrompt: String, + messages: [(role: ChatMessage.Role, content: String)], + session: URLSession, + onDelta: (String) -> Void + ) async throws { + var wire: [[String: Any]] = [["role": "system", "content": systemPrompt]] + for m in messages { wire.append(["role": m.role.rawValue, "content": m.content]) } + + var body: [String: Any] = ["model": model, "messages": wire, "stream": true] + body["temperature"] = 0.6 + body["max_tokens"] = 4096 + + var req = URLRequest(url: AIProvider.openAI.endpoint) + req.httpMethod = "POST" + req.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try JSONSerialization.data(withJSONObject: body) + + try await performStreamingRequest(req, session: session) { payload in + if let delta = SseDeltas.openAiDelta(payload) { + onDelta(delta) + } + } + } + func fetchModels(key: String, session: URLSession) async throws -> [String] { var req = URLRequest(url: AIProvider.openAI.modelsEndpoint) req.httpMethod = "GET" diff --git a/Strand/App/ActiveWorkoutClock.swift b/Strand/App/ActiveWorkoutClock.swift new file mode 100644 index 0000000000..b6798cf730 --- /dev/null +++ b/Strand/App/ActiveWorkoutClock.swift @@ -0,0 +1,50 @@ +import Foundation + +/// The ONE pause-aware elapsed calculation for an in-flight workout, shared by every surface that shows +/// its clock. +/// +/// #1533 added Pause/Resume to the full-screen live workout view and taught THAT view's timer to subtract +/// the paused time. The Today "workout in progress" indicator and the Live tab's active-workout card were +/// not part of it: both still read `now - start`. So pausing froze one clock and left two others counting +/// up, and a wearer who paused and backed out saw the session apparently still running. The saved duration +/// was correct the whole time, which is what made it confusing rather than merely wrong — the only +/// evidence on screen contradicted the button that had just been pressed. +/// +/// Kept pure and in one place so a new surface cannot reintroduce the divergence by open-coding the +/// subtraction again. Twin of Kotlin `ActiveWorkoutClock`. +enum ActiveWorkoutClock { + + /// Seconds of ACTIVE time: wall time since `start`, minus every completed pause, minus the one still + /// open if the session is paused right now. + /// + /// Clamped at zero so a clock-skew negative reads 0:00 rather than counting backwards. The open-pause + /// term is deliberately NOT clamped on its own: the single clamp on the result is what the Kotlin twin + /// does, and two clamps would disagree with it for a `pausedAt` in the future. + /// + /// Twin of Kotlin `ActiveWorkoutClock.activeElapsedSeconds` — the NAMES differ (this one takes + /// `Date`/`TimeInterval`, that one Long milliseconds), so neither turns up in a grep for the other. + /// Same arithmetic and the same single clamp; the platforms truncate to whole seconds at different + /// points and agree because both truncate toward zero. + static func activeElapsed(start: Date, pausedAt: Date?, pausedDuration: TimeInterval, + now: Date = Date()) -> TimeInterval { + max(0, now.timeIntervalSince(start) - pausedDuration + - (pausedAt.map { now.timeIntervalSince($0) } ?? 0)) + } + + /// `M:SS` up to an hour, `H:MM:SS` once an hour has passed, so a 90-minute session reads "1:30:00" + /// rather than "90:00". Negative input clamps to "0:00". + /// + /// The Live card used to carry its own `%d:%02d` formatter with no hour roll-over at all, so a long + /// session read "90:00" there while the Today indicator beside it read "1:30:00" — and Android's + /// shared `elapsedClock` documents itself as mirroring the iOS one, which by then it no longer did. + /// Twin of Kotlin `elapsedClock`. + static func clock(_ seconds: Int) -> String { + let total = max(0, seconds) + let h = total / 3600 + let m = (total % 3600) / 60 + let s = total % 60 + return h > 0 + ? String(format: "%d:%02d:%02d", h, m, s) + : String(format: "%d:%02d", m, s) + } +} diff --git a/Strand/App/AppClock.swift b/Strand/App/AppClock.swift new file mode 100644 index 0000000000..371ae251c7 --- /dev/null +++ b/Strand/App/AppClock.swift @@ -0,0 +1,122 @@ +import Foundation +import StrandAnalytics + +/// #1821: the app-side reader for the Clock format setting. Sits next to `AppLanguage` because it +/// answers the same shape of question - what conventions do we render in - and because the bug it fixes +/// lives in `AppLanguage.activeLocale`. +/// +/// `activeLocale` builds `language_REGION` on purpose, to keep the device's regional date order and +/// clock style. But a locale built from a REGION identifier carries that region's DEFAULT hour cycle, +/// and the user's explicit Settings > General > Date & Time > 24-Hour Time switch is not part of it - +/// that override lives on `Locale.autoupdatingCurrent`. So the switch was silently discarded, and a +/// reader in a 24-hour region could not get a 12-hour clock by any means at all. +enum AppClock { + /// The stored preference, defaulting to `.system` so an upgrade changes nobody's display. + static var preference: ClockFormatPreference { + ClockFormatPreference.from(stored: UserDefaults.standard.string(forKey: ClockFormatPreference.defaultsKey)) + } + + /// What the DEVICE is set to, read from `autoupdatingCurrent` precisely because that is the locale + /// the 24-Hour Time switch modifies. + /// + /// #1829: MEMOISED. This calls `DateFormatter.dateFormat(fromTemplate:options:locale:)`, which builds + /// an ICU pattern generator - one of the more expensive things Foundation offers - and the first cut + /// of this ran it on EVERY access, ahead of the formatter cache. Since ~17 call sites had just been + /// converted from `static let` to computed properties, that put an ICU build behind every time label + /// on screen. The cache saved the cheap allocation and missed the expensive call entirely. + static var systemUses24Hour: Bool { + // Arm the invalidator HERE, not in hourMinuteFormatter(): four call sites reach AppClock only + // through `formattingLocale` and would never have registered it, leaving their memo pinned to a + // stale clock for the life of the process. Every path lands here — Swift evaluates both arguments + // of ClockFormat.uses24Hour(preference:systemUses24Hour:) eagerly — so this is the one chokepoint. + // Touched before the lock: registration does not need it, and taking it twice would not nest. + _ = localeObserver + cacheLock.lock(); defer { cacheLock.unlock() } + if let cachedSystem24 { return cachedSystem24 } + let template = DateFormatter.dateFormat(fromTemplate: "j", options: 0, + locale: Locale.autoupdatingCurrent) ?? "H" + let v = !template.contains("a") + cachedSystem24 = v + return v + } + + static var uses24Hour: Bool { + ClockFormat.uses24Hour(preference: preference, systemUses24Hour: systemUses24Hour) + } + + /// #1829: memoised for the same reason - `AppLanguage.activeLocale` reads + /// `Bundle.main.preferredLocalizations` and then CONSTRUCTS a `Locale`, per access. + static var activeLocale: Locale { + cacheLock.lock(); defer { cacheLock.unlock() } + if let cachedActiveLocale { return cachedActiveLocale } + let l = AppLanguage.activeLocale + cachedActiveLocale = l + return l + } + + /// The locale to hand any time-rendering `DateFormatter`. Carries an explicit hour cycle, so the + /// setting also reaches formatters that never name a pattern (`timeStyle`, a `"jmm"` template). + static var formattingLocale: Locale { + let id = ClockFormat.hourCycleLocaleIdentifier(base: activeLocale.identifier, + uses24Hour: uses24Hour) + cacheLock.lock(); defer { cacheLock.unlock() } + if let cachedFormattingLocale, cachedFormattingLocaleID == id { return cachedFormattingLocale } + let l = Locale(identifier: id) + cachedFormattingLocale = l + cachedFormattingLocaleID = id + return l + } + + private static var cached: (template: String, locale: String, formatter: DateFormatter)? + private static var cachedSystem24: Bool? + private static var cachedActiveLocale: Locale? + private static var cachedFormattingLocale: Locale? + private static var cachedFormattingLocaleID: String? + /// One lock for every cache here. Each accessor takes it around its own read-modify-write and + /// releases before returning, so none of them nest - NSLock is not recursive and a nested take would + /// deadlock the main thread, which is a far worse failure than the cost this exists to avoid. + private static let cacheLock = NSLock() + + /// Drop every memo. Called when the reader changes the setting, and when the SYSTEM clock or locale + /// changes underneath us - `systemUses24Hour` can flip with no write of our own, so without this the + /// memo would pin a stale answer for the life of the process. + static func invalidate() { + cacheLock.lock() + cached = nil + cachedSystem24 = nil + cachedActiveLocale = nil + cachedFormattingLocale = nil + cachedFormattingLocaleID = nil + cacheLock.unlock() + } + + /// #1829: registers exactly once, on first use — a `static let` is lazy and thread-safe in Swift, so + /// touching it below is the whole registration. The system 24-hour switch and the device locale can + /// both change with no write from us; without this the memo above would serve a stale answer for the + /// rest of the process, which is worse than the per-access cost it replaced. + private static let localeObserver: NSObjectProtocol = NotificationCenter.default.addObserver( + forName: NSLocale.currentLocaleDidChangeNotification, object: nil, queue: nil + ) { _ in AppClock.invalidate() } + + /// Wall-clock time at minute precision, honouring the setting. + static func hourMinuteFormatter() -> DateFormatter { + let template = ClockFormat.hourMinuteTemplate(uses24Hour: uses24Hour) + let locale = activeLocale + cacheLock.lock(); defer { cacheLock.unlock() } + if let cached, cached.template == template, cached.locale == locale.identifier { + return cached.formatter + } + let f = DateFormatter() + f.locale = locale + f.setLocalizedDateFormatFromTemplate(template) + cached = (template, locale.identifier, f) + return f + } + + /// Convenience for the many call sites that just want the string. + static func hourMinute(_ date: Date) -> String { hourMinuteFormatter().string(from: date) } + + static func hourMinute(unix ts: Int) -> String { + hourMinute(Date(timeIntervalSince1970: TimeInterval(ts))) + } +} diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index 4de5e170f9..9e215541e4 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -118,9 +118,10 @@ final class AppModel: ObservableObject { var isPaused: Bool { pausedAt != nil } + /// Delegates to `ActiveWorkoutClock` so this and the two card surfaces cannot drift apart again. func elapsed(at now: Date = Date()) -> TimeInterval { - max(0, now.timeIntervalSince(start) - pausedDuration - - (pausedAt.map { now.timeIntervalSince($0) } ?? 0)) + ActiveWorkoutClock.activeElapsed(start: start, pausedAt: pausedAt, + pausedDuration: pausedDuration, now: now) } } struct HealthAlert: Equatable { @@ -541,8 +542,12 @@ final class AppModel: ObservableObject { registry: registry, live: live, storeHandle: { [weak self] in await self?.repo.storeHandle() }, - startWhoop: { [weak self] in self?.scan() }, - stopWhoop: { [weak self] in self?.disconnect() }, + // #1881: the flag rides the SAME two closures, so it inherits the coordinator's semantics + // exactly — including the deliberate Apple Watch exception, which calls neither and so must + // never stop a live WHOOP. `stopWhoop` alone was edge-triggered: it dropped the link once and + // nothing stopped `poweredOn` / state restoration / the standing connect bringing it back. + startWhoop: { [weak self] in self?.ble.setWhoopIsActiveDevice(true); self?.scan() }, + stopWhoop: { [weak self] in self?.ble.setWhoopIsActiveDevice(false); self?.disconnect() }, // WHOOP targeting hooks , thin wrappers over BLEManager's existing additive setters, so the // coordinator never references BLEManager directly (mirrors the start/stop injection). On the // single-WHOOP path these are setPreferredPeripheral(nil) and (no setActiveDeviceId call), @@ -559,6 +564,12 @@ final class AppModel: ObservableObject { }) coordinator.start() self.deviceRegistry = registry + // #1303: adoption re-points the strap onto its stable `whoop-` id inside BLEManager (which + // holds only the non-observable store), so mirror it onto the OBSERVABLE registry here or the + // Devices screen and the source coordinator keep watching an id that no longer exists. + self.ble.onSerialIdentityAdopted = { [weak registry] serialId in + registry?.setActive(serialId) + } self.sourceCoordinator = coordinator // #814 READ SPINE (HIGH-1): drive the read side off the registry's `activeDeviceId` for the WHOLE // session, exactly as SourceCoordinator drives the WRITE side off the SAME publisher. A Devices- @@ -634,7 +645,7 @@ final class AppModel: ObservableObject { // analyzeRecent tick , otherwise a just-synced night's Charge / Effort / Rest can take up to // 15 minutes to appear on a strap-only (no-import) dashboard. analyzeRecent no-ops if a tick is // already running and refreshes the dashboard itself once the new scores persist. (PR #218) - // #1196/#1146: `skipIfUnchanged` gates THIS post-offload pass on the HR fingerprint — an empty/ + // #1196/#1146: `skipIfUnchanged` gates THIS post-offload pass on the complete raw-input fingerprint — an empty/ // duplicate offload (nothing new banked, common on a flapping link) skips the whole-window rescore // instead of churning it, which was surfacing as a Trends/streak "0 days" flicker. Only this // post-offload caller opts in; every other analyzeRecent path still forces unconditionally. @@ -1357,7 +1368,14 @@ final class AppModel: ObservableObject { /// `log` (optional): strap-log sink for the not-authorized bail (#401 close-out) — a silent no-op left a /// user whose backup never fired with nothing in the log. The caller wraps the sink in a main-actor hop /// (the auth check completes off-main). Diagnostic only. + /// + /// #1864: `overrides` carries the per-weekday wake-time overrides (#554 / `WindDownNudge.perDayWakeOverrides`). + /// A weekday with an override fires at ITS OWN time, not the shared `minutes` — so a user who sets + /// "Tuesday 03:30" on the alarm screen is woken at 03:30 on Tuesday, not at the default time with only + /// the wind-down reminder shifting. An empty map (the default) is byte-for-byte the old path. Mirrors + /// Android's `SmartAlarmScheduler.arm` which reads `SmartAlarmStore.targetOverrides` per weekday. static func scheduleSmartAlarmBackupNotification(minutes: Int, weekdays: Set, + overrides: [Int: Int] = [:], log: ((String) -> Void)? = nil) { #if os(iOS) let center = UNUserNotificationCenter.current() @@ -1372,6 +1390,9 @@ final class AppModel: ObservableObject { let valid = weekdays.filter { (1...7).contains($0) } // A non-empty selection that filters to nothing (only out-of-range numbers) has no day to fire on. if !weekdays.isEmpty && valid.isEmpty { return } + // #1864: only valid override entries (day 1…7, minute in [0, 1440)) count; a day without an override + // uses the default `minutes`. When the map is empty this is byte-for-byte the old path. + let cleanOverrides = overrides.filter { (1...7).contains($0.key) && (0..<24 * 60).contains($0.value) } // Build + add the repeating trigger(s). Factored so the already-authorized and the just-granted // paths schedule identically. @@ -1380,20 +1401,37 @@ final class AppModel: ObservableObject { content.title = String(localized: "Smart alarm") content.body = String(localized: "Backup wake: your smart alarm time is here.") content.sound = .default - let hour = minutes / 60 - let minute = minutes % 60 if weekdays.isEmpty { - var comps = DateComponents() - comps.hour = hour - comps.minute = minute - let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true) - center.add(UNNotificationRequest(identifier: smartAlarmBackupId, content: content, trigger: trigger)) + // Every day. When overrides exist, fan out to per-weekday triggers (each at its own time) + // so an override on a day the weekday set doesn't restrict still fires at the right time. + // Without overrides this stays the single daily trigger (byte-for-byte the old path). + if cleanOverrides.isEmpty { + let hour = minutes / 60 + let minute = minutes % 60 + var comps = DateComponents() + comps.hour = hour + comps.minute = minute + let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true) + center.add(UNNotificationRequest(identifier: smartAlarmBackupId, content: content, trigger: trigger)) + } else { + for weekday in 1...7 { + let m = cleanOverrides[weekday] ?? minutes + var comps = DateComponents() + comps.weekday = weekday + comps.hour = m / 60 + comps.minute = m % 60 + let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true) + center.add(UNNotificationRequest(identifier: "\(smartAlarmBackupId)-d\(weekday)", + content: content, trigger: trigger)) + } + } } else { for weekday in valid { + let m = cleanOverrides[weekday] ?? minutes var comps = DateComponents() comps.weekday = weekday // Calendar weekday 1=Sun…7=Sat , fires weekly on that day - comps.hour = hour - comps.minute = minute + comps.hour = m / 60 + comps.minute = m % 60 let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true) center.add(UNNotificationRequest(identifier: "\(smartAlarmBackupId)-d\(weekday)", content: content, trigger: trigger)) @@ -1434,14 +1472,25 @@ final class AppModel: ObservableObject { /// On iOS this ALSO (dis)arms the best-effort backup wake notification (#4 + #6): a repeating daily /// `UNCalendarNotificationTrigger` that survives suspend/relaunch, so a missed strap buzz still gets /// an OS-level wake. macOS keeps just the firmware alarm (the static helpers are no-ops there). + /// + /// #1864: the per-weekday wake-time overrides (`WindDownNudge.perDayWakeOverrides`, #554) now flow + /// through to BOTH the strap firmware alarm and the backup notification — so a user who sets + /// "Tuesday 03:30" on the alarm screen is woken at 03:30 on Tuesday, not at the default time with + /// only the wind-down reminder shifting. Before this, the overrides had exactly two readers + /// (`wakeMinutes(forWeekday:)` → the nudge fan-out, and `SmartAlarmView` which edits them) and the + /// alarm backup took a single time plus a day set, so the control on the alarm screen silently moved + /// only the evening reminder. Mirrors Android's `reconcileStrapAlarm` which passes `dayOverrides` + /// to `nextSmartAlarmEpochSec`, and `SmartAlarmScheduler.arm` which reads `targetOverrides`. func applySmartAlarm() { + let overrides = WindDownNudge.perDayWakeOverrides guard behavior.smartAlarmEnabled else { ble.disableStrapAlarm() Self.cancelSmartAlarmBackupNotification() return } guard let next = Self.nextSmartAlarmDate(minutes: behavior.smartAlarmMinutes, - weekdays: behavior.smartAlarmWeekdays) else { + weekdays: behavior.smartAlarmWeekdays, + overrides: overrides) else { // No enabled weekday in the next week (only possible from a corrupted set) , disarm rather // than arm a misleading time the user never asked for. ble.disableStrapAlarm() @@ -1454,6 +1503,7 @@ final class AppModel: ObservableObject { // @MainActor - the same Task hop the importTraceSink uses. Self.scheduleSmartAlarmBackupNotification(minutes: behavior.smartAlarmMinutes, weekdays: behavior.smartAlarmWeekdays, + overrides: overrides, log: { [weak self] line in Task { @MainActor in self?.live.append(log: line) } }) @@ -1463,28 +1513,39 @@ final class AppModel: ObservableObject { /// - `minutes`: target wake time, minutes since local midnight. /// - `weekdays`: Calendar weekday numbers (1 = Sun … 7 = Sat) the alarm may fire on. Empty = every /// day. Days outside 1…7 are ignored. + /// - `overrides`: per-weekday wake-time overrides (#554 / #1864). A weekday with an override uses + /// ITS OWN time instead of `minutes`; a day without one falls back to `minutes`. Only valid + /// entries (day 1…7, minute in [0, 1440)) count. An empty map is byte-for-byte the old path. /// Returns the next strictly-future date matching the time on an enabled weekday, scanning today /// plus the next 7 days, or nil if no enabled weekday falls in that range. Pure + side-effect-free /// so it can be unit-tested against a fixed clock. nonisolated static func nextSmartAlarmDate(minutes: Int, weekdays: Set, + overrides: [Int: Int] = [:], from now: Date = Date(), calendar cal: Calendar = .current) -> Date? { let valid = weekdays.filter { (1...7).contains($0) } // An empty input means "every day" (backward compatible). A non-empty selection that filters to // nothing (only out-of-range numbers) has no valid day to fire on, so it's nil, not a daily alarm. if !weekdays.isEmpty && valid.isEmpty { return nil } - let hour = minutes / 60 - let minute = minutes % 60 + // #1864: only valid override entries (day 1…7, minute in [0, 1440)) count; a day without an + // override uses the default `minutes`. When the map is empty this is byte-for-byte the old path. + let cleanOverrides = overrides.filter { (1...7).contains($0.key) && (0..<24 * 60).contains($0.value) } // Scan today (offset 0) through +7 days so a once-a-week alarm picked for "today, already // passed" still resolves to the same weekday next week. for offset in 0...7 { - guard let day = cal.date(byAdding: .day, value: offset, to: now), - let fire = cal.date(bySettingHour: hour, minute: minute, second: 0, of: day) - else { continue } + guard let day = cal.date(byAdding: .day, value: offset, to: now) else { continue } + // Resolve this calendar day's weekday FIRST, so the per-day override time is applied BEFORE + // the strictly-future check — a later override time on today can make today's occurrence + // still pending (mirrors Android's `nextSmartAlarmEpochSec`). + let dow = cal.component(.weekday, from: day) + if !weekdays.isEmpty && !valid.contains(dow) { continue } + let wakeMin = cleanOverrides[dow] ?? minutes + let hour = wakeMin / 60 + let minute = wakeMin % 60 + guard let fire = cal.date(bySettingHour: hour, minute: minute, second: 0, of: day) else { continue } if fire <= now { continue } - if weekdays.isEmpty { return fire } - if valid.contains(cal.component(.weekday, from: fire)) { return fire } + return fire } return nil } @@ -1671,6 +1732,17 @@ final class AppModel: ObservableObject { } } + /// The reader's temperature unit, resolved the way every screen resolves it. AppModel is not a View, + /// so there is no @AppStorage — but the resolution must stay identical (explicit override when set, + /// else derived from the unit system), or the alert banner and the Skin Temp card disagree about + /// what unit the same number is in. + private var displayTemperatureUnit: TemperatureUnit { + let d = UserDefaults.standard + let system = UnitSystem(rawValue: d.string(forKey: UnitPrefs.systemKey) ?? "") ?? .metric + return UnitPrefs.resolveTemperature(system: system, + override: d.string(forKey: UnitPrefs.temperatureKey) ?? "") + } + /// Run the `IllnessSignalEngine` from the day history + the journal-derived confounder context, then /// publish the result + the semantic `healthAlert` banner payload. private func applyIllnessSignal(_ days: [DailyMetric], alcohol: Bool, @@ -1741,8 +1813,21 @@ final class AppModel: ObservableObject { labels["hrv"] = String(localized: "HRV −\(percent)%") } if let r = rm({ $0.skinTempDevC }), r > 0 { - let temperature = String(format: "%.1f", locale: AppLanguage.activeLocale, r) - labels["skinTemp"] = String(localized: "Skin temperature +\(temperature) °C") + // The value is STORED in °C but must be SHOWN in the reader's unit: this label welded "°C" + // into the translated string, so a Fahrenheit user got "+0.7 °C" from the banner while every + // other surface rendered the same night as "+1.3 Δ°F". + // + // #111/#622: skinTempDevC is BIMODAL — an imported night is an ABSOLUTE wrist °C, a live one + // a signed DEVIATION — so neither the conversion nor the unit chip may be assumed. Kind and + // chip come from SkinTempDisplay, the same authority the Today and Health tiles use. The + // NUMBER is formatted here rather than by SkinTempDisplay because these decimals go through + // AppLanguage.activeLocale: a German reader sees "0,7", which the package's plain + // String(format:) would flatten to "0.7". + let temperature = UnitFormatter.skinTempSignalPhrase( + r, + fahrenheit: displayTemperatureUnit == .fahrenheit, + locale: AppLanguage.activeLocale) + labels["skinTemp"] = String(localized: "Skin temperature \(temperature)") } if let r = rm({ $0.respRateBpm }), let b = mean(base.compactMap { $0.respRateBpm }), r > b { labels["respiration"] = String(localized: "Respiration up") diff --git a/Strand/App/ContentView.swift b/Strand/App/ContentView.swift index 43b312d241..c1d1067e7f 100644 --- a/Strand/App/ContentView.swift +++ b/Strand/App/ContentView.swift @@ -53,6 +53,15 @@ struct ContentView: View { // Seed the current What's New into the Updates inbox (idempotent per version) so the bell // collects it even if the user dismisses the auto sheet. UpdateStore.shared.seedWhatsNewIfNeeded() + // #1659: NOOP is sideloaded on every platform, so nothing else will tell you a release + // happened. On by default and switchable off - see UpdateAvailability.defaultEnabled. + // + // Gated on the SAME condition as showWhatsNewIfDue below, matching the iOS and Android hooks: + // a default-on check must not reach the network during first run, before the Terms gate has + // been accepted. (No sideload sentence here - macOS has no seven-day re-sign and no AltStore.) + if onboarded && acceptedTerms == Terms.currentVersion { + UpdateWatch.runIfDue(currentVersion: UpdateWatch.installedVersion, sideloadHint: false) + } } .onChangeCompat(of: acceptedTerms) { _ in showWhatsNewIfDue() } } diff --git a/Strand/App/GpsWorkoutRecorder.swift b/Strand/App/GpsWorkoutRecorder.swift index 5a00533429..eb6e7dfc17 100644 --- a/Strand/App/GpsWorkoutRecorder.swift +++ b/Strand/App/GpsWorkoutRecorder.swift @@ -254,9 +254,27 @@ enum RouteStore { /// route has no usable polyline (so we never store an empty placeholder — honest "no route"). static func store(_ route: WorkoutRoute, startTs: Int, sport: String, into defaults: UserDefaults = .standard) { - guard !route.polyline.isEmpty else { return } + storeAll([(route, startTs, sport)], into: defaults) + } + + /// Persist MANY routes in one blob rewrite. + /// + /// `store` reads, decodes, mutates, re-encodes and writes the whole map. That is right for the + /// recorder, which calls it once when a workout ends. An IMPORT calls it per workout, and the map is + /// capped at [maxRoutes], so N imported routes cost N full decode/encode cycles of a map that grows + /// to 400 entries: at roughly 7 KB per polyline (an hour at 1 Hz) a 500-workout first import churns + /// on the order of a gigabyte of JSON through `UserDefaults`, on a phone. Batching makes it one pass. + /// + /// Eviction is unchanged and applied ONCE after the whole batch, so an import that overflows the cap + /// drops the oldest by `startTs` exactly as a sequence of single stores would have — and since + /// imported history is older than anything recorded since, a large import evicts its own oldest + /// entries rather than the routes this device recorded. + static func storeAll(_ entries: [(route: WorkoutRoute, startTs: Int, sport: String)], + into defaults: UserDefaults = .standard) { + let usable = entries.filter { !$0.route.polyline.isEmpty } + guard !usable.isEmpty else { return } var map = loadMap(from: defaults) - map[key(startTs: startTs, sport: sport)] = route + for e in usable { map[key(startTs: e.startTs, sport: e.sport)] = e.route } if map.count > maxRoutes { // Keys lead with the startTs, so a lexicographic sort by the numeric prefix evicts the oldest. let ordered = map.keys.sorted { lhs, rhs in diff --git a/Strand/App/NavRouter.swift b/Strand/App/NavRouter.swift index c0fc3e9b8f..da031e5d1f 100644 --- a/Strand/App/NavRouter.swift +++ b/Strand/App/NavRouter.swift @@ -29,6 +29,11 @@ final class NavRouter: ObservableObject { case activeWorkout case liveSession case journal + /// #1862: the Coach screen, opened from the Today Coach launcher sheet once the user picks a + /// suggestion or submits the composer. The launcher deliberately owns no send/stream/consent + /// surface of its own — it hands the question to the one screen that already has them. + /// Also the K5 scheduled morning-brief notification's tap-through target. + case coach var id: String { rawValue } @@ -60,6 +65,8 @@ final class NavRouter: ObservableObject { /// Ask the shell to open the Devices manager (pair / switch bands). The shell decides how. func openDevices() { requestedDestination = .devices } + /// #1862: open Coach, optionally with a question the launcher already collected. + func openCoach() { requestedDestination = .coach } /// Open the v5 Insights hub (the n-of-1 "what moves your Charge" surface). func openInsightsHub() { requestedDestination = .insightsHub } /// Open the Lab Book (private health-records logbook). diff --git a/Strand/App/RootView.swift b/Strand/App/RootView.swift index a7aa0483c7..b5bb0222f8 100644 --- a/Strand/App/RootView.swift +++ b/Strand/App/RootView.swift @@ -328,6 +328,9 @@ struct RootView: View { case .liveSession: selection = .today // The #627 Today journal widget routes to the Insights sidebar row (which hosts the journal card). case .journal: selection = .insights + // #1862: the Today Coach card's launcher hands off here, so the send/stream/consent surface + // stays in exactly one place. + case .coach: selection = .coach case nil: break } if dest != nil { router.requestedDestination = nil } diff --git a/Strand/App/StrandApp.swift b/Strand/App/StrandApp.swift index 1ed234b393..588e83a2e4 100644 --- a/Strand/App/StrandApp.swift +++ b/Strand/App/StrandApp.swift @@ -22,12 +22,16 @@ struct StrandApp: App { // Foreground presentation: without a delegate, macOS suppresses a notification's banner while the // app is frontmost, so a reminder tested with NOOP open would show nothing. Mirrors iOS. UNUserNotificationCenter.current().delegate = NotificationPresenter.shared + // K5: tapping a scheduled morning-brief notification routes to Coach via the shared NavRouter. + let router = NavRouter() + _router = StateObject(wrappedValue: router) + NotificationPresenter.shared.onCoachBriefTapped = { [weak router] in router?.openCoach() } } @StateObject private var model = AppModel() /// Shared cross-screen navigation hook (e.g. Live → Devices). The macOS shell (`RootView`) /// observes it and drives the sidebar selection. - @StateObject private var router = NavRouter() + @StateObject private var router: NavRouter /// #267: drives a foreground sync kick when the window becomes active (no scenePhase hook /// existed on macOS before this). @Environment(\.scenePhase) private var scenePhase diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index a9db87dbc5..87cc75888f 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -148,9 +148,13 @@ struct PostBondTimeoutLoopDetector { /// The streak accumulates across the reconnect loop (a disconnect does NOT reset it) and is cleared only by /// a genuine bond or an explicit user reconnect, exactly like BLEManager's existing `bondRefusalStreak`. struct BondRefusalGiveUp { - /// Consecutive bond refusals before we pause auto-reconnect + write the epitaph. 5 (not 2, where the + /// Consecutive bond refusals before we PAUSE auto-reconnect + write the epitaph. 5 (not 2, where the /// pairing HINT already shows): the hint asks the user to act; we give them several reconnect cycles to /// do it before we stop hammering. A genuinely held/stale strap reaches 5 within a couple of minutes. + /// + /// This is the AUTH-REFUSAL number specifically, and `recordRefusal` takes a per-refusal override for + /// that reason: an unanswered handshake asks the user for nothing, so waiting five cycles for a decision + /// they cannot make just buys ~4.8s link drops. See `giveUpThresholdFor`. let giveUpThreshold: Int private(set) var refusals = 0 @@ -162,9 +166,9 @@ struct BondRefusalGiveUp { /// Record one bond refusal. Returns true if THIS refusal freshly crossed the give-up threshold (so the /// caller pauses the reconnect + writes the epitaph exactly once). - mutating func recordRefusal() -> Bool { + mutating func recordRefusal(threshold: Int? = nil) -> Bool { refusals += 1 - if !gaveUp && refusals >= giveUpThreshold { + if !gaveUp && refusals >= (threshold ?? giveUpThreshold) { gaveUp = true return true } @@ -191,6 +195,48 @@ struct BondRefusalGiveUp { "NOOP stopped retrying because your strap keeps refusing to pair. It is likely still held by the official WHOOP app, or your phone is holding an old pairing. Close the WHOOP app, put the strap in pairing mode (tap until the LEDs flash blue), and if it is listed in your Bluetooth settings choose Forget This Device. Then tap Connect to try again." } + /// #1635: the log epitaph for the SUPPRESSION path. + /// + /// Separate from [epitaphLine] because that one asserts a cause ("almost certainly held by the + /// official WHOOP app") that only an auth refusal supports. Reusing it here would print a confident + /// explanation for a write that simply vanished. + /// + /// Pure. Byte-identical to the Kotlin `BondRefusalGiveUp.helloSuppressedEpitaph`. + static func helloSuppressedEpitaph(refusals: Int, opaqueId: String) -> String { + "Bond epitaph: the strap [\(opaqueId)] never acknowledged the secure handshake \(refusals)x in a row, and the attempt is what drops the link - leaving the handshake off so live heart rate keeps streaming. Tap Connect to try it again." + } + + /// #1635: the hint shown when the hello is switched off and the link is KEPT. + /// + /// Nothing is paused on this branch, so it must not say auto-reconnect stopped. It names what was lost + /// and what to try, without asserting a cause. + /// + /// #1635 follow-up: it used to end "Tap Connect to try the handshake again", which framed a strap that + /// REFUSES pairing as a retryable failure — inviting exactly the hammering the give-up latch exists to + /// stop. A field report read all of this and still asked how to fix it, so the ending now leads with + /// the only thing anyone has recovered from this state with (pairing mode, reported once on #1635 and + /// hedged accordingly) and keeps Connect as the follow-up. It also no longer claims HRV and resting + /// heart rate are unavailable: since #1884 an HR-only night reports both. + /// + /// Pure. Byte-identical to the Kotlin `BondRefusalGiveUp.helloSuppressedHint`. + static func helloSuppressedHint() -> String { + "The secure handshake with your strap never completes, and the attempt itself is what drops the link. NOOP has switched it off for this strap so live heart rate keeps streaming. History sync stays unavailable until it pairs, and so do motion, skin temperature, SpO₂ and respiratory rate, so sleep is staged from heart rate alone. Some straps have paired again after being put in pairing mode. Tap until the LEDs flash blue, then tap Connect." + } + + /// The paused hint for a bond that failed WITHOUT the strap ever answering (#1635). + /// + /// [pausedHint] names a cause — the strap still held by the official WHOOP app, or a stale OS pairing — + /// which is well founded when the refusal arrived as INSUFFICIENT_AUTHENTICATION or + /// INSUFFICIENT_ENCRYPTION: the strap actively said no. It is NOT founded when the CLIENT_HELLO simply + /// goes unanswered and the link drops on a timer, which is a different observation with several + /// possible causes. Telling that user to close the WHOOP app would be a guess dressed as instruction, + /// and if it is wrong they have no way to know. + /// + /// Pure. Byte-identical to the Kotlin `BondRefusalGiveUp.pausedHintHandshakeUnanswered`. + static func pausedHintHandshakeUnanswered() -> String { + "NOOP stopped retrying because the secure handshake with your strap never completes: the strap does not answer, and the link drops a few seconds later. Auto-reconnect is paused so it stops draining both batteries. Tap Connect to try again, and if it keeps happening please share your strap log." + } + /// #750: a short OPAQUE token from a CoreBluetooth-local peripheral UUID for the epitaph. The CB UUID is /// per-install (NOT the hardware MAC), and we keep only its first 8 hex chars, so the token is stable /// within a log but carries no device-identifying PII. Pure + deterministic. Twin of the Android helper. @@ -507,6 +553,17 @@ public final class BLEManager: NSObject, ObservableObject { static let disService = CBUUID(string: "180A") static let disSerialChar = CBUUID(string: "2A25") // Serial Number String static let disHwRevChar = CBUUID(string: "2A27") // Hardware Revision String + static let disFwRevChar = CBUUID(string: "2A26") // Firmware Revision String + static let disManufacturerChar = CBUUID(string: "2A29") // Manufacturer Name String + static let disModelNumberChar = CBUUID(string: "2A24") // Model Number String + static let disSwRevChar = CBUUID(string: "2A28") // Software Revision String + /// Every DIS characteristic NOOP reads, in ONE place. The failure reporter tests membership here, so + /// a characteristic added beside these cannot go silently unreported — the exact "a refused read is + /// indistinguishable from one never issued" gap the reporter exists to close. Kotlin twin: DIS_CHARS. + static let disChars: Set = [ + disSerialChar, disHwRevChar, disFwRevChar, + disManufacturerChar, disModelNumberChar, disSwRevChar, + ] static let restoreID = "com.openwhoop.ble.central" @@ -515,6 +572,11 @@ public final class BLEManager: NSObject, ObservableObject { private let router: FrameRouter private var collector: Collector? /// #716: stored on bootstrap so the scan callback can fix the seeded "WHOOP" model label. + /// #1303: fired after the strap's history has been re-pointed onto its stable `whoop-` id. + /// The live persist paths are already re-pointed inline by then; this exists so the OBSERVABLE spine + /// (`DeviceRegistry`, and the coordinator that watches it) follows too — the store write above is not + /// observable, so without this the UI would keep showing the old id until relaunch. + var onSerialIdentityAdopted: ((String) -> Void)? private var registryStore: DeviceRegistryStore? /// #716: true once the seeded "WHOOP" model has been stamped to the correct family. private var modelStamped = false @@ -583,6 +645,13 @@ public final class BLEManager: NSObject, ObservableObject { /// Pure battery-adaptive gate (#477), the twin of Android `WhoopBleClient.idleThrottleActive`. Keyed /// on the STRAP's battery: armed by `thresholdPct` > 0, engages while the strap is discharging at/below /// `thresholdPct`. The phone's own Low Power Mode deliberately does NOT trigger it. Charging never engages. + /// + /// `charging` is not purely "is charging" (#1935): on a 5/MG it is also set on pack ATTACH, so a + /// depleted or badly-seated pack reads true while nothing charges, and this gate then stays off. The + /// window is bounded — the next BATTERY_LEVEL rewrites the flag from the strap's own gauge — and the + /// reasoning for accepting that rather than splitting the state is on `LiveState.charging`. Read it + /// before adding a trend check here: making this wait for a rising gauge would delay throttle release + /// on every honest attach to close an edge that already closes itself. static func lowPowerThrottleActive(batteryPct: Int, charging: Bool, thresholdPct: Int) -> Bool { thresholdPct > 0 && !charging && batteryPct <= thresholdPct } @@ -671,6 +740,29 @@ public final class BLEManager: NSObject, ObservableObject { /// DIAGNOSTIC ONLY — it never cancels or retries; recovery is separate work. private var pendingConnectProbe: DispatchWorkItem? static let pendingConnectProbeSeconds: TimeInterval = 15 + /// #1809: per-connection inbound accounting for the link epitaph. A strap log could not say whether + /// the strap transmitted anything; these three make it a measurement. Reset in `didConnect`, so they + /// describe THIS link and never carry a previous session's traffic into its epitaph. + private var inboundFrames = 0 + private var inboundBytes = 0 + private var cmdChannelFrames = 0 + /// #1635: rows ACCEPTED on this link, split by PATH. The realtime decoder yields only hr/rr/events/ + /// battery; gravity, resp, skinTemp, SpO2 and steps arrive solely through the offload, so counting one + /// path and naming streams from the other prints a constant rather than a finding. Cleared with the + /// frame counters. Plain vars like their neighbours: both feeders hand up on the main actor. + private var liveHr = 0 + private var liveRr = 0 + private var offloadHr = 0 + private var offloadRr = 0 + private var offloadGravity = 0 + private var offloadResp = 0 + private var offloadSkinTemp = 0 + private var offloadSpo2 = 0 + /// Chunks the offload actually persisted on this link — separates "never ran" from "nothing new". + private var offloadChunks = 0 + + /// Uptime clock for the epitaph. Monotonic, so a wall-clock change mid-link cannot make it negative. + private var linkUpSince: DispatchTime? /// Last time ANY notification arrived — drives the liveness watchdog. private var lastDataAt = Date() /// True while a Live/Health screen is on-screen and wants the realtime stream. One of the two @@ -801,6 +893,8 @@ public final class BLEManager: NSObject, ObservableObject { /// Re-entrancy guard for captureRawAccel: true while a bounded on-demand window is running. /// A second tap is a no-op until the active capture's asyncAfter block fires and clears this. private var rawCaptureInFlight = false + private var rawCaptureStoppedAt = Date.distantPast + private var unexpectedImuStopAt = Date.distantPast /// Ordered queue of frames awaiting drain through the serial Backfiller task. private var backfillFrameQueue: [[UInt8]] = [] /// True while the drain task is running (prevents a second drain task from launching). @@ -895,9 +989,11 @@ public final class BLEManager: NSObject, ObservableObject { private var dataNotifyCharacteristic: CBCharacteristic? private var heartRateCharacteristic: CBCharacteristic? private var batteryCharacteristic: CBCharacteristic? - /// #520 DIS: remembered at discovery, READ post-bond (see the #490 note — a 5/MG refuses standard - /// reads before the link is encrypted). Serial + hardware revision are immutable, so they are read - /// ONCE per connection (`disRead`), never re-polled like the battery. + /// #520 DIS: remembered at discovery, READ post-bond — and since #1635 also on a SUPPRESSED link that + /// will never bond, which is now a permanent state rather than a transient one. The #490 note (a 5/MG + /// refuses standard reads before the link is encrypted) is the open question that attempt settles + /// either way. Serial + hardware revision are immutable, so they are read ONCE per connection + /// (`disRead`), never re-polled like the battery. /// Peripheral whose GATT tree has already been dumped, so the ~30-line enumeration is emitted once per /// device rather than once per connect (#1635). Not persisted: a fresh launch is exactly when the tree /// is worth seeing again, and a strap whose firmware changed between runs may expose something new. @@ -906,8 +1002,40 @@ public final class BLEManager: NSObject, ObservableObject { private var disSerialCharacteristic: CBCharacteristic? private var disHwRevCharacteristic: CBCharacteristic? private var disRead = false + /// 3.0s — the Kotlin twin's `BATTERY_ON_CONNECT_DELAY_MS * 2` (1500 × 2), and the same 1.5s on-connect + /// pacing this file already uses before `requestSync(.connect)`. Long enough that the link has settled + /// after the suppression decision, short enough to land in the same session. Pinned to the twin's + /// number rather than an independently chosen one: this read can cost the link, so the two platforms + /// should not be probing at different moments when a field capture has to explain a drop. + static let unbondedDisReadDelay: TimeInterval = 3.0 + /// #1193: the WHOOP 4.0 strap serial from `GET_HELLO_HARVARD`, once it has been seen TWICE. + /// + /// The withholding rule itself lives in `RepeatedSerialGate`, beside the decoder, where it is unit + /// tested; this pair is just the manager's view of its outcome. + /// + /// The 5/MG adopts its DIS serial on first read, because `0x2A25` is a spec-defined field that means + /// one thing. The 4.0 offset is not that: it was read off a single capture, so "the 9-char alnum run + /// at offset 14" is a strong inference rather than a documented field. If that run turned out to be + /// per-session rather than per-strap, adopting it immediately would mint a NEW id on every connect and + /// migrate the history each time — strictly worse than the duplicate row this exists to prevent. + /// + /// So a value must repeat before it is trusted. Two hellos carrying the same run cannot both be a + /// fresh per-session token, which is the only failure mode that would do real damage. The cost is that + /// a 4.0 adopts one connect later than a 5/MG; the benefit is that the destructive failure cannot + /// happen at all. Deliberately NOT reset on disconnect — the two sightings are meant to span connects. + private var harvardSerialGate = RepeatedSerialGate() + private var harvardSerialConfirmed: String? + private var disSerial: String? private var disHwRev: String? + /// #1635 follow-up: the DIS identity extras (firmware, manufacturer, model, software revision) as + /// discovered. Held as a list rather than one property each because nothing branches on them + /// individually — they are read as a set and reported as a set. + private var disExtraCharacteristics: [CBCharacteristic] = [] + /// The DIS firmware string (0x2A26), which is the ONLY firmware source an unbonded 5/MG has: the + /// puffin report needs the bond that never happens. Published only as a fallback, never as an + /// override — see `shouldPublishDisFirmware`. + private var disFirmware: String? /// EXPERIMENTAL WHOOP 5.0/MG puffin notify chars (fd4b0003/4/5/7), remembered at discovery so we /// can re-subscribe them AFTER bonding — the strap refuses them ("Authentication is insufficient") /// until the link is encrypted (issue #17). @@ -915,6 +1043,11 @@ public final class BLEManager: NSObject, ObservableObject { private var reassembler = Reassembler() private var seq: UInt8 = 0 private var didBond = false + /// #1635: one explicit Connect grants one fresh CLIENT_HELLO even when the suppression latch is set. + /// Consumed unconditionally by the write site, so the retry belongs to THIS session — leaving it set + /// would hand a stale retry to some later automatic reconnect and restart the loop the suppression + /// exists to end. Kotlin twin: `WhoopBleClient.helloRetryRequested`. + private var helloRetryRequested = false /// WHOOP 5/MG only: realtime HR has been armed (puffin TOGGLE_REALTIME_HR sent) once for this /// connection, so the post-bond callback re-firing on later `.withResponse` writes doesn't re-send it. private var whoop5RealtimeArmed = false @@ -1020,6 +1153,7 @@ public final class BLEManager: NSObject, ObservableObject { /// `connectFromSystem`'s targeted path already makes: no new outbound command, nothing written to the /// strap, so the BLE safety contract is unaffected. Twin of `OuraLiveSource.issueStandingConnect`. private func issueStandingConnect(whilePausedForBondLoop: Bool = false) { + guard whoopConnectAllowed("standing-connect") else { return } guard !intentionalDisconnect else { return } // #1539: the bond-loop pause suppresses this by default, but the paused paths deliberately opt in — // a parked, timeout-free connect is what lets that pause end without the user. @@ -1129,9 +1263,40 @@ public final class BLEManager: NSObject, ObservableObject { /// 4.0, or an unidentified strap all read false. var isWhoop5MG: Bool { whoop5Variant.isMG } + /// Whether a WHOOP is the device the user actually selected (#1881). FAIL-OPEN: `true` unless + /// something positively says otherwise, so the single-WHOOP path and every unknown state behave + /// exactly as before — a wrong `false` here would stop the strap connecting for everyone. + /// + /// This is the DURABLE form of the coordinator's `stopWhoop()`. That call is edge-triggered — it + /// fires once on the WHOOP→other-source transition and nothing re-asserts it — while the WHOOP flow + /// has system-driven entry points that consult nobody: `poweredOn` (every Bluetooth toggle and + /// `bluetoothd` restart), state restoration, and the standing connect CoreBluetooth honours while + /// the app is suspended. So a radio toggle resurrected a strap the user had switched away from, + /// drained its history, and — before the identity fix below — filed every row under the ACTIVE + /// device's id. Reported with a proven case: 770 `stepSample` rows under an Oura ring, which has no + /// pedometer. + /// + /// Set from the same two closures the coordinator already uses to stop and start WHOOP, so the + /// deliberate exception survives untouched: an Apple Watch becoming active must NOT stop a live + /// WHOOP, and `switchToAppleWatch` calls neither closure. + private var whoopIsActiveDevice = true + + /// The last entry point `whoopConnectAllowed` turned away, so the block is logged once per path + /// rather than on every tick of the family-rotation timer. + private var lastBlockedConnectReason: String? + + /// #1635: one `ScanAdvertisementSummary` line per SCAN, not per process — the question it answers + /// is only visible by comparing a scan before the strap was put in pairing mode against one after, + /// so `startScan` reopens it. + private var advertisementLogged = false + /// Stable device id; matches the server's existing device for sync parity. Overridable. /// Seeded from the init argument, then refined once in bootstrapStore() to the device registry's /// active id (still "my-whoop" today) before any store writes use it — see bootstrapStore(). + /// + /// #1881: this is "the device these bytes came from", NOT "the active device". Those were the same + /// thing only while every registered device was a WHOOP; `adoptSourceIdentity(for:)` re-points it + /// per connection from the peripheral that actually connected. private(set) var deviceId: String /// Captured (device↔wall) correlation from GET_CLOCK; nil until the response lands. private(set) var clockRef: ClockRef? @@ -1156,7 +1321,10 @@ public final class BLEManager: NSObject, ObservableObject { // before any BLE data arrives. self.collector = nil super.init() - state.lastSyncedAt = UserDefaults.standard.object(forKey: "lastSyncedAt") as? Double + // Deliberately NOT seeded from the global key here. It belongs to whichever strap synced last, + // which on a two-strap install is not the one the screens are scoped to — the misattribution this + // whole change removes. `seedLastSyncFromActiveStrap` fills it from the ACTIVE strap once the + // registry exists (bootstrapStore); a blank moment is honest, another strap's timestamp is not. // Restore identifier + background-capable central (foundation for M3 state restoration). #if os(iOS) // iOS background state preservation/restoration: the restore identifier is what makes @@ -1170,12 +1338,47 @@ public final class BLEManager: NSObject, ObservableObject { #endif // Strap-as-clock: an incoming EVENT packet kicks a rate-limited catch-up sync. router.onSyncTrigger = { [weak self] in self?.requestSync(.strap) } + router.onStrapSerial = { [weak self] serial in self?.noteHarvardSerial(serial) } // #1193 // #78 hole-4: a paused-for-bond-loop strap gets one bounded salvage attempt per app-foreground. installForegroundSalvageProbe() } /// Build the WhoopStore + Collector + Backfiller asynchronously. Safe to call multiple /// times — bails out early if the collector is already initialised. + /// Seed the last-sync display from the ACTIVE strap's own stamp — PR #556's intent, correctly attributed. + /// + /// Resolved against the registry's active row, exactly as the debug export resolves firmware. NOT from + /// the legacy global key, which belongs to whichever strap synced last: on a two-strap install that is + /// not the strap the screens are scoped to, and seeding from it would put one strap's sync time on the + /// other's Today screen — the defect this change exists to remove. + /// + /// The legacy global still applies when exactly one strap is paired, because only then can it not be + /// ambiguous (see `LastSyncAttribution.resolve`). Called from `bootstrapStore` because that is the + /// first point the registry exists; the initialisers deliberately seed nothing. + /// + /// Never overwrites a value this session has already earned: a HISTORY_COMPLETE landing while this + /// runs is newer than anything persisted and must win. + private func seedLastSyncFromActiveStrap(registry: DeviceRegistryStore) { + // Registry + defaults resolved off-actor, then a single hop to publish. The `lastSyncedAt == nil` + // check lives INSIDE that hop and nowhere else: checking it out here as well would be a + // check-then-act across an await, and the value it guards is exactly the one a HISTORY_COMPLETE + // can set while this runs. + let rows = (try? registry.all()) ?? [] + let activeId = (try? registry.activeDeviceId()) ?? nil + let activeAddr = rows.first(where: { $0.id == activeId })?.peripheralId + let d = UserDefaults.standard + guard let seed = LastSyncAttribution.resolve( + perDevice: LastSyncAttribution.prefKey(peripheralId: activeAddr) + .flatMap { d.object(forKey: $0) as? Double }, + legacyGlobal: d.object(forKey: "lastSyncedAt") as? Double, + pairedCount: rows.count) else { return } + Task { @MainActor [state] in + // Never overwrite a value this session earned: a HISTORY_COMPLETE landing while the registry + // read was in flight is newer than anything persisted, and must win. + if state.lastSyncedAt == nil { state.lastSyncedAt = seed } + } + } + func bootstrapStore() async { guard collector == nil else { return } // Surface store-open failures instead of swallowing them with `try?` (#222): a silent failure @@ -1205,9 +1408,25 @@ public final class BLEManager: NSObject, ObservableObject { // its own concurrency). let registry = DeviceRegistryStore(dbQueue: store.registryWriter) self.registryStore = registry - if let activeId = try? registry.activeDeviceId(), - !activeId.isEmpty { - self.deviceId = activeId + seedLastSyncFromActiveStrap(registry: registry) + if let activeId = try? registry.activeDeviceId(), !activeId.isEmpty { + // #1881: adopt the active id ONLY when the active device is a WHOOP. This is the line the + // report quotes, and its comment stated the assumption that falsified it — with a ring active + // this set `deviceId` to the RING, and every live sample and historical chunk persisted under + // it. `adoptSourceIdentity` corrects the id when a strap connects, but not for the window + // between here and that connect, so the wrong value must not be adopted in the first place. + // + // Fail-open on an unknown row (nil): unchanged behaviour for anything the registry can't + // classify. Only a POSITIVELY non-WHOOP active device is refused, and that same fact seeds the + // connect gate — which closes the launch race where `poweredOn` can reach the WHOOP flow + // before `SourceCoordinator` has wired up and asserted it. + let activeRow = (try? registry.all())?.first(where: { $0.id == activeId }) + if let activeRow, !SourceIdentity.isWhoop(activeRow) { + setWhoopIsActiveDevice(false) + log("Active device \(activeId) is not a WHOOP — leaving sample attribution on \(deviceId) (#1881)") + } else { + self.deviceId = activeId + } } // Look up the active device's real brand/model instead of a hardcoded string — this predated // multi-device support and mislabeled every non-"WHOOP 4.0" device (a WHOOP 5.0/MG strap, an Oura @@ -1219,7 +1438,12 @@ public final class BLEManager: NSObject, ObservableObject { // persists raw frames. Flip "enableRawCapture" in UserDefaults to capture raw again. let enableRawCapture = UserDefaults.standard.bool(forKey: "enableRawCapture") collector = Collector(store: store, deviceId: deviceId, - enableRawCapture: enableRawCapture) + enableRawCapture: enableRawCapture, + log: { [weak self] line in self?.log(line) }, + onBanked: { [weak self] c in + // Live path: hr/rr are all the realtime decoder yields. + self?.liveHr += c.hr; self?.liveRr += c.rr + }) // The store can finish bootstrapping AFTER connect(model:) already ran (both wait on // poweredOn), so apply the family/clock configuration here too — whichever runs last wins. configureCollectorFamily() @@ -1227,6 +1451,13 @@ public final class BLEManager: NSObject, ObservableObject { ackTrim: { [weak self] trim, endData in self?.ackHistoricalChunk(trim: trim, endData: endData) }, + onBankedOffload: { [weak self] c in + guard let self else { return } + self.offloadChunks += 1 + self.offloadHr += c.hr; self.offloadRr += c.rr + self.offloadGravity += c.gravity; self.offloadResp += c.resp + self.offloadSkinTemp += c.skinTemp; self.offloadSpo2 += c.spo2 + }, enableRawCapture: enableRawCapture, log: { [weak self] s in self?.log(s) }, rejectedSink: { [weak self] frames, trim, family in @@ -1292,7 +1523,10 @@ public final class BLEManager: NSObject, ObservableObject { self.router = FrameRouter(state: state) self.collector = collector super.init() - state.lastSyncedAt = UserDefaults.standard.object(forKey: "lastSyncedAt") as? Double + // Deliberately NOT seeded from the global key here. It belongs to whichever strap synced last, + // which on a two-strap install is not the one the screens are scoped to — the misattribution this + // whole change removes. `seedLastSyncFromActiveStrap` fills it from the ACTIVE strap once the + // registry exists (bootstrapStore); a blank moment is honest, another strap's timestamp is not. // Restore identifier + background-capable central (mirrors the production initializer // so a restored manager matches by identifier; only exercised by tests/previews). #if os(iOS) @@ -1304,12 +1538,19 @@ public final class BLEManager: NSObject, ObservableObject { #endif // Strap-as-clock: an incoming EVENT packet kicks a rate-limited catch-up sync. router.onSyncTrigger = { [weak self] in self?.requestSync(.strap) } + router.onStrapSerial = { [weak self] serial in self?.noteHarvardSerial(serial) } // #1193 // #78 hole-4: a paused-for-bond-loop strap gets one bounded salvage attempt per app-foreground. installForegroundSalvageProbe() } // MARK: Public API + /// Give buffered standard-HR rows a persistence attempt before app suspension/termination. + /// Kept on BLEManager so the app lifecycle reaches the live Collector owned by this connection. + func flushStandardHRForLifecycle(reason: LivePersistTrace.StandardHRFlushReason) async { + await collector?.flushStandardHR(reason: reason) + } + /// USER-initiated connect (the Connect button, the scan flow, Add-a-WHOOP). The ONLY entry that /// re-arms a bond-loop give-up: a user gesture is an explicit "try again", so the streak + pause /// clear and auto-reconnect works again if it bonds. System-initiated paths (Bluetooth poweredOn, @@ -1325,6 +1566,17 @@ public final class BLEManager: NSObject, ObservableObject { autoReconnectPausedForBondLoop = false bondLoopPausedAt = nil } + // #1635: the suppression path pauses NOTHING, so the reset above never fires for it. An explicit + // Connect is exactly the signal that the user has acted (pairing mode, closed the WHOOP app), so + // it must grant a fresh handshake regardless. Be exact about what that does and does not do: it + // grants ONE connect's worth of hello and leaves the latch standing, so if the strap still will + // not answer, the automatic reconnects behind this attempt stay suppressed. The latch is cleared + // by the attempt SUCCEEDING (didWriteValueFor's genuine ack) or by forgetDevice, never by the tap + // itself — Kotlin `pairingHintClearDropsSuppressionLatch` is the twin of that rule, and it had to + // be made one: clearing on the tap there re-paid the whole give-up on every press. + // Deliberately NOT in connectCore: a system-initiated reconnect must not. + helloRetryRequested = true + bondGiveUp.reset() connectCore(model: model) } @@ -1335,6 +1587,12 @@ public final class BLEManager: NSObject, ObservableObject { /// path schedules nothing afterwards, so the hammer loop cannot restart. A genuine bond still fully /// resets via the didWriteValueFor path, so a strap freed since the give-up self-heals. func connectFromSystem(model: WhoopModel = .persisted) { + // #1881: the gate belongs HERE, not in the shared `connectCore`. This file already draws the line + // the fix needs — `connect()` is the user's explicit Connect button, and every system-initiated + // path "MUST use connectFromSystem()" — and the report's complaint is only ever about NOOP acting + // on its own. Gating the shared core instead would have made the Connect button silently dead + // while the Devices screen showed a "Reconnecting…" toast. + guard whoopConnectAllowed("connect-from-system") else { return } connectCore(model: model) } @@ -1353,6 +1611,7 @@ public final class BLEManager: NSObject, ObservableObject { // and tell the router which decoder to use. Fresh per connection so no stale bytes carry over. reassembler = Reassembler(family: model.deviceFamily) router.family = model.deviceFamily + router.deviceId = deviceId // #1706: attribute this connection's alarm readback // Live 5/MG persistence: point the Collector's decode at the selected family and install the // identity clock ref for a 5/MG (its live timestamps are already real unix). WHOOP 4.0 keeps // the GET_CLOCK correlation flow untouched. Re-applied after bootstrapStore builds the @@ -1447,6 +1706,9 @@ public final class BLEManager: NSObject, ObservableObject { public func forgetDevice(_ peripheralId: String?) { let target = peripheralId.flatMap { UUID(uuidString: $0) } let isCurrent = target == nil || peripheral?.identifier == target + // Captured BEFORE the teardown below nils `peripheral`, since a nil argument means "the current + // strap" and that is the only place its identifier can still be read. + let releasedUUID = peripheralId ?? peripheral?.identifier.uuidString intentionalDisconnect = true // defuses the disconnect→3s-reconnect loop's guard cancelScanFallback() readoptingTo = nil // abandon any in-flight #52 pin handoff @@ -1467,6 +1729,12 @@ public final class BLEManager: NSObject, ObservableObject { } // #747/#750 invariant: releasing a strap fully resets the give-up + pause (like disconnect()) so // a paused state can never outlive the strap it belonged to and wedge a later re-add. + // + // #1635: the hello-suppression latch is exactly that kind of state, and it is PERSISTED - so + // without this it outlives the strap it belonged to, silently suppressing the handshake on a + // re-add and leaving a stale key behind for a strap the user removed. Re-latching costs the same + // five refusals it always did, so a strap that genuinely cannot bond is no worse off. + HelloSuppressionStore.setSuppressed(releasedUUID, false) bondGiveUp.reset() autoReconnectPausedForBondLoop = false bondLoopPausedAt = nil @@ -1701,6 +1969,84 @@ public final class BLEManager: NSObject, ObservableObject { deviceId = id collector?.deviceId = id backfiller?.deviceId = id + // #1881: the alarm readback attributes through the router's own copy (#1706), which this used to + // leave pointing at the previous device. Same field, same conflation, one more consumer. + router.deviceId = id + } + + /// Record whether a WHOOP is the active device (#1881). Called from the SAME two closures the + /// `SourceCoordinator` already uses to stop and start the WHOOP, so it inherits their semantics + /// exactly — including the deliberate Apple Watch exception, which calls neither. + /// + /// Re-asserts itself: if a WHOOP link is already up when a different source becomes active, the + /// coordinator's own `stopWhoop()` drops it: this only stops it coming back on its own. + public func setWhoopIsActiveDevice(_ active: Bool) { + guard whoopIsActiveDevice != active else { return } + whoopIsActiveDevice = active + log(active + ? "WHOOP is the active device again — scanning and reconnecting are allowed" + : "WHOOP is no longer the active device — not scanning or connecting until it is again (#1881)") + } + + /// The one gate every WHOOP scan/connect entry point passes through (#1881). + /// + /// Deliberately at the CONNECT sites rather than only at `connectCore`: there are five + /// `central.connect` sites, and `connectCore` owns two. The rest are reached from `poweredOn` and + /// `willRestoreState` (via `connectRestored`), from `didDiscover`, and from the standing connect — + /// which involves no scan at all and is honoured while the app is suspended, so no scan-side guard + /// can cover it. + /// + /// `reason` names the entry point in the strap log, so a "why did my ring's data grow legs" report + /// says which path tried. Logged only on the transition into blocking, so a rotation timer cannot + /// flood the log. + private func whoopConnectAllowed(_ reason: String) -> Bool { + if whoopIsActiveDevice { return true } + // The flag is a CACHE of a registry fact, and the registry is the authority. Re-validate before + // refusing, so the gate can never latch: it is set from the coordinator's stop/start closures and + // seeded at bootstrap, and if those ever disagree — the coordinator failing to wire after the seed + // said "not a WHOOP", say — a stale `false` would stop the strap connecting for the whole session. + // Only on the blocked path, so the common case still costs nothing. + if let registry = registryStore, let activeId = try? registry.activeDeviceId() { + let row = (try? registry.all())?.first(where: { $0.id == activeId }) + if row.map(SourceIdentity.isWhoop) ?? true { + setWhoopIsActiveDevice(true) + return true + } + } + if lastBlockedConnectReason != reason { + lastBlockedConnectReason = reason + log("Not connecting the WHOOP (\(reason)): a different device is active (#1881)") + } + return false + } + + /// Attribute this connection to the strap that ACTUALLY connected, not to whatever device is active + /// (#1881). Resolves the registry row whose `peripheralId` matches the connected peripheral and + /// re-points `deviceId` + the in-flight `Collector`/`Backfiller`/router to it. + /// + /// Conservative by construction — it only ever moves the id to a row that is BOTH a WHOOP and + /// matched by peripheral: + /// • no registry / unreadable → leave the id alone (today's behaviour). + /// • no row matches this peripheral → leave it alone. This is the legacy single-WHOOP case before + /// the row has adopted a `peripheralId`; `SourceCoordinator.connectedPeripheralChanged` adopts it + /// on this same connect, so the following one resolves. + /// • the matched row is not a WHOOP → leave it alone; nothing else reaches this delegate. + /// • already correct → no write. + /// + /// Known narrow residual: the disconnect handler flushes buffered 0x2A37 HR in a deliberately + /// fire-and-forget `Task`, and the Collector reads `deviceId` at PERSIST time — so a flush still in + /// flight when the next link re-points the id would attribute the previous link's tail to the new + /// strap. It needs a re-point to happen at all, which only occurs when the resolved row differs from + /// the current id: never on a settled single-WHOOP install. In the reported case the move is from the + /// ring to the strap, and those buffered rows are the STRAP's, so the new attribution is the correct + /// one. The genuinely wrong case is two WHOOPs where A's tail lands on B — a pre-existing hazard of + /// `setActiveDeviceId`, which the coordinator already calls on a WHOOP→WHOOP switch. + private func adoptSourceIdentity(for peripheral: CBPeripheral) { + guard let registry = registryStore, let rows = try? registry.all() else { return } + guard let resolved = SourceIdentity.resolve(address: peripheral.identifier.uuidString, + rows: rows, currentId: deviceId) else { return } + log("Attributing this link to \(resolved) — the strap that connected, not the active device (#1881)") + setActiveDeviceId(resolved) } /// Add-a-WHOOP wizard: scan the selected family's WHOOP service and surface every nearby strap in @@ -1756,8 +2102,14 @@ public final class BLEManager: NSObject, ObservableObject { rawCaptureInFlight = true let secs = RawCaptureWindow.clamp(seconds) collector?.beginRawCapture(seconds: secs) - send(.startRawData, payload: [0x01]) - send(.toggleIMUMode, payload: [0x01]) + // Hardware-verified on WHOOP 5/MG: opcode 106 alone acknowledges but does not start the + // producer. START_RAW_DATA must precede the two-byte realtime IMU selector. + send(.startRawData, payload: [0x01], writeType: .withResponse) + if selectedModel.deviceFamily == .whoop5 { + send(.toggleIMUMode, payload: [0x01, 0x01], writeType: .withResponse) + } else { + send(.toggleIMUMode, payload: [0x01], writeType: .withResponse) + } log("Raw-accel capture: started for \(secs)s") DispatchQueue.main.asyncAfter(deadline: .now() + secs) { [weak self] in guard let self else { return } @@ -1765,7 +2117,10 @@ public final class BLEManager: NSObject, ObservableObject { // continuous stream must keep running — we just flush/upload the bounded window we // captured without halting the wider session. if !UserDefaults.standard.bool(forKey: "enableRawCapture") { - self.send(.stopRawData, payload: [0x01]) + self.send(.stopRawData, payload: [0x01], writeType: .withResponse) + if self.selectedModel.deviceFamily == .whoop5 { + self.send(.toggleIMUMode, payload: [0x01, 0x00], writeType: .withResponse) + } } self.rawCaptureInFlight = false Task { @MainActor in @@ -1775,6 +2130,50 @@ public final class BLEManager: NSObject, ObservableObject { } } + /// Start a manually stopped 5/MG raw-data session. Returns false when another capture is active. + @discardableResult + public func startGroundTruthRawCapture(sessionId: String) -> Bool { + guard !rawCaptureInFlight else { return false } + rawCaptureInFlight = true + send(.startRawData, payload: [0x01], writeType: .withResponse) + send(.toggleIMUMode, + payload: selectedModel.deviceFamily == .whoop5 ? [0x01, 0x01] : [0x01], + writeType: .withResponse) + log("Raw-data session: started") + return true + } + + /// Stop and flush the current manually controlled raw-data session. + public func stopGroundTruthRawCapture() async { + if rawCaptureInFlight && !UserDefaults.standard.bool(forKey: "enableRawCapture") { + send(.stopRawData, payload: [0x01], writeType: .withResponse) + if selectedModel.deviceFamily == .whoop5 { + send(.toggleIMUMode, payload: [0x01, 0x00], writeType: .withResponse) + } + } + rawCaptureInFlight = false + rawCaptureStoppedAt = Date() + log("Raw-data session: stopped + flushed") + } + + /// Stop a realtime IMU producer left armed after a crash, lost stop write, or another client. + private func stopUnexpectedRealtimeImu(_ frame: [UInt8], isOffload: Bool, now: Date = Date()) { + guard selectedModel.deviceFamily == .whoop5, !isOffload, frame.count > 8, + frame[8] == 43 || frame[8] == 51, + !rawCaptureInFlight, !UserDefaults.standard.bool(forKey: "enableRawCapture"), + now.timeIntervalSince(rawCaptureStoppedAt) >= 3, + now.timeIntervalSince(unexpectedImuStopAt) >= 30 else { return } + unexpectedImuStopAt = now + send(.stopRawData, payload: [0x01], writeType: .withResponse) + send(.toggleIMUMode, payload: [0x01, 0x00], writeType: .withResponse) + log("Raw IMU fail-safe: unexpected realtime packet type \(frame[8]) while capture was off; stop requested") + } + + public func groundTruthHistoryCSV(from: Int, to: Int) async -> Data { + await collector?.historySensorsCSV(from: from, to: to) + ?? Data("stream,unix_s,v1,v2,v3,v4\n".utf8) + } + /// Send a command to the WHOOP strap. /// - Parameters: /// - command: The command to send. @@ -1857,6 +2256,10 @@ public final class BLEManager: NSObject, ObservableObject { || (DeviceConfigReadProbe.isReadOnlyOpcode(command.rawValue) && deviceConfigReport != nil) || command == .sendHistoricalData || command == .historicalDataResult || command == .setClock || command == .getClock + // Bounded Raw Data Collector only. These writes remain impossible unless the explicit + // user-started capture window is in flight; normal sync never enables this gate. + || (rawCaptureInFlight && (command == .startRawData + || command == .stopRawData || command == .toggleIMUMode)) // SET_CONFIG / SET_FF_VALUE (120), ENABLE direction — the R22 deep-stream unlock. Allowed // only while the deep-data experiment is opted in, and only for a KEY and a VALUE the gate // recognises: one of the sixteen R22 flags carrying that key's own enable value. The clause @@ -2340,8 +2743,27 @@ public final class BLEManager: NSObject, ObservableObject { // STALLED on a persist failure" — the latter (usually a restore without a restart) is otherwise // invisible in a report that just shows "0 synced". let du = UserDefaults.standard - if (backfiller?.sessionRowsPersisted ?? 0) > 0 { du.set(Date().timeIntervalSince1970, forKey: "sync.lastWriteOkAt") } - if backfiller?.persistStalled == true { du.set(Date().timeIntervalSince1970, forKey: "sync.lastWriteStalledAt") } + // Per strap, not per install. The global keys reported one strap's write health on another's + // screen — see LastSyncAttribution. This site has the peripheral, so it is the one place that + // can attribute them honestly (same argument as #1634's firmware write). + let whoOk = LastSyncAttribution.writeHealthPrefKey(peripheralId: peripheral?.identifier.uuidString, + kind: "lastWriteOkAt") + let whoStalled = LastSyncAttribution.writeHealthPrefKey(peripheralId: peripheral?.identifier.uuidString, + kind: "lastWriteStalledAt") + // The global keys are STILL written, deliberately, exactly as `noop.lastFirmware` still is + // (FrameRouter). DebugDataDiagnostics.strapStateLines is sync and prefs-only by contract, so it + // cannot resolve per device — and dropping the global write would not make that line + // per-device, it would freeze it at whatever it held before the upgrade and then read "no rows + // ever persisted" forever, for everyone. A stale global is the smaller lie than a dead one, and + // every reader that CAN attribute prefers the per-device key. + if (backfiller?.sessionRowsPersisted ?? 0) > 0 { + du.set(Date().timeIntervalSince1970, forKey: "sync.lastWriteOkAt") + if let k = whoOk { du.set(Date().timeIntervalSince1970, forKey: k) } + } + if backfiller?.persistStalled == true { + du.set(Date().timeIntervalSince1970, forKey: "sync.lastWriteStalledAt") + if let k = whoStalled { du.set(Date().timeIntervalSince1970, forKey: k) } + } if unarchived > 0 { state.lastSyncError = "Synced, but \(archived + unarchived) record(s) couldn't be decoded (unrecognised strap firmware layout), and the on-device archive is full - the \(unarchived) newest weren't preserved. Please share a strap log so the layout can be mapped." } else if archived > 0 { @@ -2356,8 +2778,39 @@ public final class BLEManager: NSObject, ObservableObject { ? "console-only across \(state.consoleChunksThisSession) chunks" : "metadata-only, 0 sensor rows persisted" log("Backfill: completed but the strap banked no sensor history (\(detail)); consecutive empty syncs = \(emptySyncTracker.consecutiveEmptySyncs).") + // #1683: say HOW OLD the strap's newest stored record is. Without it the line above reads + // identically for a strap that is caught up and one that stopped banking three weeks ago - + // GET_DATA_RANGE already told us the difference and we simply never said so, which is why + // #1541 stayed open and unactionable. Rare-event evidence, so always-on. + let wallNowUnix = Int(Date().timeIntervalSince1970) + // Read the field ONCE: it feeds both the log line and the banner below, and they must not + // disagree about whether the strap is stale. + // WHOOP4 only, explicitly, on BOTH platforms. Redundant here today - `strapNewestTs` is + // only assigned when `feedsSync` (#695), which is WHOOP4 - but stated so a later widening + // of feedsSync cannot make this fire where the Android twin does not. A 5/MG that cannot + // offload has no business being told it stopped saving to flash. + let staleNewest: Int? = selectedModel.deviceFamily == .whoop4 + ? strapNewestTs.flatMap { + Backfiller.isStaleNewestRecord(newestUnix: $0, wallNowUnix: wallNowUnix) ? $0 : nil + } + : nil + if let newest = staleNewest { + log(Backfiller.staleRecordLine(newestUnix: newest, wallNowUnix: wallNowUnix)) + } + // #1683: when the strap's own newest record dates the silence, SAY it. The generic copy + // below omits that and promises a recovery the charge advice has already been retried for + // every session; the dated version is the one a stuck user can act on. + // + // #1754: the generic "clock lost sync" copy is only correct when the strap reported + // trim=0xFFFFFFFF (no valid flash cursor). A strap with a valid, advancing flash cursor + // that banks no sensor records has a different problem — the sensor front-end or power, + // not the clock — and telling the user to charge it sends them away from the real cause. + // The two states are distinguished by `backfiller.sawNoFlashCursor`. state.lastSyncError = sustainedEmpty - ? "Synced, but your strap had no stored history to hand over - only its diagnostic output. This usually means its clock has lost sync, so it isn't saving data to flash. Fully charge it to 100%, then reconnect, and it should start banking again." + ? (staleNewest.map { Backfiller.staleRecordBanner(newestUnix: $0, wallNowUnix: wallNowUnix) } + ?? (backfiller?.sawNoFlashCursor == true + ? Backfiller.noFlashCursorBanner + : Backfiller.noSensorRecordsBanner)) : nil } else if let futureBanner = futureClockBanner { // #324/#928: the strap banked records but its newest is dated implausibly in the FUTURE @@ -2375,6 +2828,16 @@ public final class BLEManager: NSObject, ObservableObject { whoop5EmptyOffload.reset() state.historySyncExperimental = false } + // Stamped against the strap that actually completed this offload, never globally: the single + // key reported one strap's sync on another's screen, and it read as reassuring rather than + // wrong — see LastSyncAttribution. Kotlin twin: NoopPrefs.setLastSyncAtFor. + if let key = LastSyncAttribution.prefKey(peripheralId: peripheral?.identifier.uuidString) { + UserDefaults.standard.set(state.lastSyncedAt, forKey: key) + } + // Global kept for the same reason as the write-health pair above: the prefs-only debug header + // is its only remaining reader and cannot resolve per device, so dropping this write would + // freeze that line rather than fix it. It is also what `seedLastSyncFromActiveStrap`'s + // single-strap fallback reads across the upgrade. UserDefaults.standard.set(state.lastSyncedAt, forKey: "lastSyncedAt") // NOTE: the auto-continue streak is NOT reset here. A HISTORY_COMPLETE is no longer assumed to // mean "caught up" (#25): a strap whose firmware segments a deep offload into many small @@ -2465,6 +2928,27 @@ public final class BLEManager: NSObject, ObservableObject { Task { @MainActor in let frontier = await collector?.latestHRSampleTs() ?? nil let wallNow = Int(Date().timeIntervalSince1970) // #928: real wall clock, at decision time + // #1164: publish whether the strap has banked records newer than our local frontier, so the + // Today Rest card can show "Pending sync" instead of a provisional number. Same behind check + // the auto-continue predicate uses (5-min gap), computed here because this is the one path + // that already reads both values. Caught-up (or unknown) → false, never a stale "pending". + // #1164 + #928/#1012 + #1144: the bare gap is not enough. Both traps that + // `shouldAutoContinue` guards against latch this flag TRUE forever, which would pin Rest to + // "Pending sync" and never show a score — strictly worse than the provisional number this + // exists to hide. + // - a strap whose clock is set in the FUTURE reads ahead of ANY frontier, so the gap never + // closes (there is a user-facing banner for exactly that state); + // - a PHANTOM gap (a timestamp the strap will not actually offload, a console-only tail, a + // dup re-offload) advertises newer data while banking no new rows, so the frontier cannot + // advance and the gap stays open. `persistedSensorRows` is the same evidence #1144 added to + // the auto-continue predicate for this exact latch; a caught-up strap is already false via + // the gap, so gating on it only bites the phantom case. + if let n = newest, let f = frontier { + state.historyPendingSync = + !BackfillContinuation.isFutureDatedNewest(n, wallNowUnix: wallNow) + && persistedSensorRows + && (n - f) > BackfillContinuation.defaultBehindGapSeconds + } let stillConnected = state.connected && state.bonded guard BackfillContinuation.shouldAutoContinue( stillConnected: stillConnected, @@ -3726,6 +4210,20 @@ public final class BLEManager: NSObject, ObservableObject { log("ECG probe: ignored — not connected") return false } + // #1635: the MG check above used to block this incidentally on a suppressed strap, because the + // variant could only resolve from a POST-BOND DIS read and so stayed `.unknown` forever. Now that + // DIS is read on an unbonded link too, a suppressed MG can attest itself — and this gate would + // have let the probe write puffin frames to fd4b0002, the very characteristic whose write tears + // the link down. That would spend the stable link the suppression exists to buy, on commands the + // strap cannot answer unbonded anyway. + // + // The ECG GATE-WRITE path already required this and says why (#269: a config write over the + // live-HR-only link silently fails). The probe needs it for the same reason, and now also to stay + // out of the way of #1635. + guard state.encryptedBond else { + log("ECG probe: ignored — needs the full encrypted bond, not the live-HR-only link (#1635/#269)") + return false + } return true } @@ -3834,10 +4332,19 @@ public final class BLEManager: NSObject, ObservableObject { /// /// Called only while a run is armed, and only for non-offload live frames. private func noteEcgProbeFrame(_ frame: [UInt8]) { - // CRC GATE FIRST (safety contract §2: "New inbound paths must do the same"). The verdict this - // probe produces is its entire output, and a single flipped bit at byte 12 would turn a healthy - // SUCCESS into "DATA REQUEST REFUSED" — the strongest claim the report can make. So no byte of - // an unverified frame is read here, on either branch. + // INTEGRITY GATE FIRST (safety contract §2: "New inbound paths must do the same"). The verdict + // this probe produces is its entire output, and a single flipped bit at byte 12 would turn a + // healthy SUCCESS into "DATA REQUEST REFUSED" — the strongest claim the report can make. So no + // byte of an unverified frame is read here, on either branch. + // + // This is the app's only DIRECT call into the verifier rather than into a parser, so the + // widened verdict lands here first, and it CHANGES WHAT COUNTS AS EVIDENCE. The gate used to + // mean "the payload CRC32 is not demonstrably wrong"; it now also requires the CRC16 header + // checksum and the exact declared length. A frame with a damaged envelope therefore no longer + // settles a step's outcome AND no longer counts as a candidate ECG data packet — so it can + // neither produce a refusal the strap never uttered, nor pad the packet count that the report's + // silence-based signals are weighed against. Both directions are intended: silence attributed + // to nothing is a weaker claim than a verdict attributed to a corrupted frame. guard verifyFrame(frame, family: .whoop5).ok else { return } // Both COMMAND_RESPONSE spellings: 0x24 (36, what the #592/#690 handlers key on) and the puffin // alias 38, which `canonicalTypeName` folds onto the same name. Accepting both means a strap that @@ -3987,8 +4494,18 @@ public final class BLEManager: NSObject, ObservableObject { } private func keepAliveFire() { - guard state.connected, didBond else { return } - enableLiveNotifications(reason: "keepalive") + // #1635: a SUPPRESSED 5/MG never bonds, so a bare `didBond` gate switches this whole timer off for + // exactly the link that now stays up for hours - taking the liveness watchdog below with it and + // leaving a stalled stream with nothing to bounce it. That would quietly break the "stable live HR" + // the suppression exists to deliver. Android gates its twin on `bonded` (the live-HR shortcut, #69) + // and so reaches the same watchdog. + // + // Deliberately NARROWER than Android's gate: admitted only for a streaming 5/MG, and only as far as + // the watchdog. Everything past it needs the encrypted bond, so a second `didBond` guard closes the + // door again rather than letting an unbonded link issue puffin work that cannot land. + guard keepAliveMayRun(connected: state.connected, didBond: didBond, + bonded: state.bonded, family: selectedModel.deviceFamily) else { return } + if didBond { enableLiveNotifications(reason: "keepalive") } // Liveness watchdog: if NOTHING has arrived for a while, the stream/link stalled. // Bounce the connection — the auto-rescan on disconnect re-bonds and resumes streaming. // #580 / #1414: the standard 0x2A37 HR profile keeps the link genuinely alive, but its packets can @@ -4005,6 +4522,26 @@ public final class BLEManager: NSObject, ObservableObject { if let p = peripheral { central.cancelPeripheralConnection(p) } return } + // The watchdog above is the whole of the keep-alive an unbonded 5/MG can use. Everything below + // sends puffin-framed work that needs the encrypted bond. + // + // #1953: an unbonded 5/MG (the #1635 suppressed-hello case) still gets a periodic 0x2A19 battery + // read on Android — `keepAliveFire` there gates on `bonded` (the live-HR shortcut), not on the + // encrypted bond, and polls battery on the same ~60 s cadence regardless. iOS gates the tick on + // `keepAliveMayRun` (which admits `bonded && .whoop5`) but the battery read lives inside + // `enableLiveNotifications`, which is `didBond`-gated — so the tick fires and the read does not, + // and an unbonded 5/MG's battery % refreshes only when the UI asks. Poll 0x2A19 here on the SAME + // throttle `enableLiveNotifications` uses, so the two platforms read at the same cadence without + // doubling the rate on a bonded strap (this path only runs when `!didBond`). + if !didBond, selectedModel.deviceFamily == .whoop5, + let p = peripheral, let b = batteryCharacteristic, b.properties.contains(.read), + BLEManager.shouldPollWhoop5Battery(lastReadAt: lastBatteryReadAt, + charging: state.charging == true) { + p.readValue(for: b) + lastBatteryReadAt = Date() + log("Reading 5/MG battery (unbonded keep-alive) (#1953)") + } + guard didBond else { return } guard !backfilling else { return } // never poke the strap mid-offload // #927: continuous capture can be overnight-only, which makes the want TIME-dependent; nothing // else re-evaluates it while the app just sits connected, so the keep-alive tick re-derives it. @@ -4036,7 +4573,33 @@ public final class BLEManager: NSObject, ObservableObject { } // re-arm so it can't lapse keepAliveTick += 1 // #battery: ~60 s normally, ~30 s while charging (see `batteryPollDue`). - if BLEManager.batteryPollDue(tick: keepAliveTick, charging: state.charging == true) { + // + // WHOOP 4.0 ONLY (#1948). Both commands this block used to send are refused on a 5/MG before they + // leave the app: the send allowlist has no clause for `.getBatteryLevel` at all, and admits + // `.getBatteryPackInfo` only while a user-initiated probe is in flight. Two dead sends and two + // skip lines per tick, under a comment claiming the pack "rides the SAME cadence as the strap's + // own gauge". It never rode anything. + // + // Removing them cannot change what a 5/MG receives, and that is the whole argument: the allowlist + // refused both before they reached the peripheral, so no reading ever depended on either. Do not + // reach for a subtler one. An earlier draft here argued that the `didBond` letting this tick run + // had already driven the 0x2A19 read a few lines above, which is wrong twice over — + // `keepAliveMayRun` also admits a `bonded && .whoop5` tick with `didBond` false, and + // `enableLiveNotifications` is separately gated on `didBond`, so on a #1635 strap the tick fires + // and that read does not. + // + // #1953 closed that divergence: the unbonded 5/MG battery read now fires from the keep-alive tick + // itself (above the `guard didBond` line), on the SAME throttle `enableLiveNotifications` uses, so + // an unbonded 5/MG gets a periodic battery read on iOS at the same ~60 s cadence Android does. The + // pack's charge comes from the pushed pack-info event (109) on both, that flag's only writer since + // #1945. + // + // This leaves opcode 151 with NO sender on iOS, which is the state `FrameRouter` already records + // for its own decoder ("nothing sent the command, so the decoder had no caller"). Android keeps a + // user-initiated probe for it; there is no iOS twin of that, so asking a 5/MG whether it answers + // 151 at all is an Android-side question today. + if selectedModel.deviceFamily != .whoop5, + BLEManager.batteryPollDue(tick: keepAliveTick, charging: state.charging == true) { send(.getBatteryLevel, payload: []) } } @@ -4151,9 +4714,11 @@ public final class BLEManager: NSObject, ObservableObject { batteryCharacteristic = nil disSerialCharacteristic = nil disHwRevCharacteristic = nil + disExtraCharacteristics = [] disRead = false disSerial = nil disHwRev = nil + disFirmware = nil whoop5NotifyCharacteristics.removeAll() } @@ -4162,11 +4727,16 @@ public final class BLEManager: NSObject, ObservableObject { /// `allowFallback` is true, schedule a one-shot rotation to the other WHOOP family after /// `scanFallbackDelaySeconds` of no discovery — recovers reconnect when the persisted preference is /// stale after an update/restore. Discovery/connect cancels the pending rotation. (PR#195) + /// No gate here, deliberately: the ONLY callers are `connectCore` — reached from the user's Connect or + /// from the already-gated `connectFromSystem` — and this method's own family-rotation timer, which + /// cannot start a scan that one of those did not. Gating here as well would block the user's Connect. private func startScan(for model: WhoopModel, allowFallback: Bool) { + advertisementLogged = false cancelScanFallback() selectedModel = model reassembler = Reassembler(family: model.deviceFamily) router.family = model.deviceFamily + router.deviceId = deviceId // #1706: attribute this connection's alarm readback configureCollectorFamily() central.stopScan() log("Scanning for \(model.displayName)…") @@ -4206,6 +4776,7 @@ public final class BLEManager: NSObject, ObservableObject { /// #730 pending-connect probe. Both restore entry points funnel through here so the two paths can be /// told apart in a strap log. private func connectRestored(_ p: CBPeripheral, reason: String) { + guard whoopConnectAllowed("restored/\(reason)") else { return } log("Connecting to restored peripheral (\(reason)) — peripheral state=\(peripheralStateName(p.state))") central.connect(p, options: nil) pendingConnectProbe?.cancel() @@ -4278,9 +4849,80 @@ public final class BLEManager: NSObject, ObservableObject { disRead = true p.readValue(for: serialChar) if let c = disHwRevCharacteristic, c.properties.contains(.read) { p.readValue(for: c) } + readDisExtras(p) + } + } + + /// Read the DIS identity extras — firmware above all, which an unbonded 5/MG has no other source for. + /// + /// DIVERGENCE FROM ANDROID (deliberate, platform): the Kotlin twin CHAINS these one read per callback, + /// because its GATT queue runs a single operation at a time and a second read issued early is simply + /// dropped. CoreBluetooth queues reads itself, so chaining here would add a state machine to serialise + /// something already serialised. Same characteristics, same data, no chain. + /// + /// A strap need not implement all of DIS: a missing or unreadable characteristic is skipped, because + /// "not implemented" is per-characteristic and the ones it DOES publish should still be read. + private func readDisExtras(_ p: CBPeripheral) { + for c in disExtraCharacteristics where c.properties.contains(.read) { + p.readValue(for: c) } } + /// The UNBONDED DIS attempt (#1635 follow-up). Deliberately separate from the post-bond identity read, + /// which runs only inside the handshake and therefore never runs at all on a strap that will not bond. + /// + /// Delayed for the same reason the Kotlin twin delays it: this is only safe in the settled state the + /// suppression leaves behind — no handshake outstanding, nothing else in flight, the link holding. + private func scheduleUnbondedDisRead() { + DispatchQueue.main.asyncAfter(deadline: .now() + BLEManager.unbondedDisReadDelay) { [weak self] in + self?.readDisIdentityUnbonded() + } + } + + private func readDisIdentityUnbonded() { + // `peripheral` is only cleared by forgetDevice, so it OUTLIVES a disconnect - and this read is + // scheduled with a delay, on a strap whose entire failure mode is dropping the link. Without the + // connected check the reads would be issued into a dead link and the line below would claim an + // attempt that never happened, which is precisely what a diagnostic may not do. + guard state.connected, let p = peripheral else { return } + let refused = disRefusedPrefKey(p.identifier.uuidString) + .map { UserDefaults.standard.bool(forKey: $0) } ?? false + guard shouldReadDisUnbonded(isWhoop5: selectedModel.deviceFamily == .whoop5, + bonded: didBond, + alreadyReadThisLink: disRead, + previouslyRefused: refused) else { return } + guard let serialChar = disSerialCharacteristic, serialChar.properties.contains(.read) else { + log("DIS: serial characteristic unavailable — hardware variant stays unknown") + return + } + log("DIS: trying the identity read on an UNbonded link — unproven, and a refusal is itself the answer to whether DIS needs an encrypted bond (#490)") + disRead = true + p.readValue(for: serialChar) + if let c = disHwRevCharacteristic, c.properties.contains(.read) { p.readValue(for: c) } + readDisExtras(p) + } + + /// A DIS read that came back with a failure status. + /// + /// Scoped to the DIS characteristics: they are the reads whose refusal is a FINDING (it settles #490 + /// and means the firmware cannot be had without a bond). The refusal is latched per device, so a strap + /// that declines says so once instead of on every reconnect forever. + private func noteDisReadFailure(_ uuid: CBUUID, _ error: Error) { + guard BLEManager.disChars.contains(uuid) else { return } + log(disReadFailureLine(uuid: uuid.uuidString, status: error.localizedDescription)) + // Report it, but latch it ONLY for an unbonded attempt. The latch is persisted and permanent, so a + // transient failure on a BONDED link would disable the unbonded read for this strap for good and + // blame the strap for something that was never its policy. This is the analogue of the Kotlin + // twin's explicit-bond guard: same reasoning, different thing to exclude, because the bonded read + // is the only way a DIS failure reaches here without being the finding we are looking for. + guard !didBond else { + log("DIS: not latching that refusal — the link is bonded, so the failure is not attributable to the strap's unbonded-read policy") + return + } + guard let key = disRefusedPrefKey(peripheral?.identifier.uuidString) else { return } + UserDefaults.standard.set(true, forKey: key) + } + /// Resolve + log the 5/MG hardware variant once a DIS string lands (#520). Both characteristics /// arrive as SEPARATE async callbacks, so this runs on each and re-resolves — the contradiction rule /// in `Whoop5Variant` needs both before it can disagree. Diagnostic only: nothing gates on it yet. @@ -4296,6 +4938,69 @@ public final class BLEManager: NSObject, ObservableObject { // existing consumer — nothing about framing or decode reads this. state.whoop5Variant = variant.label reconcileModelFromAttestation(variant) + adoptWhoopSerialIdentity() + } + + /// The serial this pairing may adopt: the 5/MG's DIS read, or a 4.0's twice-seen hello serial. + private var adoptableSerial: String? { disSerial ?? harvardSerialConfirmed } + + /// #1193: a 4.0 hello serial arrived. Adopt only on the SECOND sighting of the same value — see + /// `harvardSerialCandidate` for why a single sighting is not enough to act on destructively. + private func noteHarvardSerial(_ serial: String) { + // Do NOT advance the gate before the store exists. The gate confirms EXACTLY ONCE, so a + // confirmation raised while `adoptWhoopSerialIdentity` would bail is not deferred, it is lost for + // the lifetime of the process — every later sighting returns nil because the value is already + // confirmed. The 5/MG path survives the same hazard only because it re-offers its DIS serial on + // every connect; a once-only gate has no such second chance, so it must not consume a sighting it + // cannot act on. Kotlin twin guards its own missing dependency the same way. + guard registryStore != nil else { return } + guard let confirmed = harvardSerialGate.offer(serial) else { return } + harvardSerialConfirmed = confirmed + adoptWhoopSerialIdentity() + } + + /// #1303: the 5/MG DIS read hands us the strap's OWN serial, so re-point this pairing from its + /// transient CoreBluetooth-UUID id onto a stable `whoop-` id — through the SAME generic + /// migration the ring already uses (`adoptSerialIdentity`, #771), so a re-pair or factory reset stops + /// forking one physical strap into a second row and orphaning its history (#1193). + /// + /// A WHOOP 4.0 reaches this by a different road. It exposes no DIS serial (the read above is gated + /// `!= .whoop4`), so its serial comes from the `GET_HELLO_HARVARD` response instead — see + /// `Whoop4HelloSerial` for the offset and `noteHarvardSerial` for why a 4.0 waits for a second + /// sighting before adopting where a 5/MG adopts on the first read (#1193). + /// + /// Deferred to the next main-loop turn, mirroring `adoptOuraSerial`: adoption re-points the ACTIVE + /// device, and the observers that react tear down and rebuild the very connection this callback is + /// running inside, so the current BLE callback must return first. Idempotent — after the first + /// adoption the id already equals the serial id, so every later reconnect costs one string compare and + /// touches no database. A serial `WhoopSerialIdentity` refuses (blank, truncated, non-serial prose) + /// leaves the strap on its existing id: adopting onto a junk id would migrate every device-scoped row + /// onto a garbage key, which is worse than not adopting. + private func adoptWhoopSerialIdentity() { + guard let rs = registryStore, + let serialId = WhoopSerialIdentity.adoptedId(serial: adoptableSerial), + let active = try? rs.all().first(where: { $0.status == .active }), + WhoopSerialIdentity.mayAdopt(currentId: active.id), + active.id != serialId + else { return } + let currentId = active.id + Task { @MainActor [weak self] in + guard let self, let rs = self.registryStore, + (try? rs.all().first(where: { $0.status == .active }))?.id == currentId + else { return } + guard (try? rs.adoptSerialIdentity(from: currentId, to: serialId)) == true else { return } + try? rs.setActive(serialId) + // The rows have MOVED to the serial id and the old registry row is gone, so the live persist + // paths must follow in the same turn: `deviceId`, the Collector and the Backfiller all stamp + // rows at write time, and leaving them on the now-deleted id would write new samples into a + // second, orphaned history — the same split this phase exists to prevent, just on the + // provisional path instead of the legacy one. Kotlin reaches the identical call through + // `SourceCoordinator.pointWhoop`, which re-points any non-legacy id. + self.setActiveDeviceId(serialId) + // Prefix only. `serialId` embeds the full serial, which must never reach a shareable log. + self.log("Adopted stable serial identity (serialPrefix=\(WhoopSerialIdentity.logSafe(serial: self.adoptableSerial))) - history re-pointed off the transient pairing id (#1303)") + self.onSerialIdentityAdopted?(serialId) + } } /// The strap's own DIS attestation is ground truth (a WHOOP 4.0 never attests a 5AM/5AG serial). When @@ -4437,6 +5142,9 @@ public final class BLEManager: NSObject, ObservableObject { private func recordAlarmArm(sentEpoch: Int) { let d = UserDefaults.standard d.set(sentEpoch, forKey: "alarm.lastArmSentEpoch") + // #1706: WHICH strap this arm went to. Without it the export, and the reject streak, compare + // this epoch against a readback that on a multi-strap install may be a different device. + d.set(deviceId, forKey: "alarm.lastArmDeviceId") d.set(Date().timeIntervalSince1970, forKey: "alarm.lastArmAt") d.set(commandChannelReady, forKey: "alarm.lastArmConnected") // #613: true only if the arm actually went out // #34: the strap-clock skew (its own RTC minus wall, seconds) AT THE MOMENT we armed. A wrong RTC @@ -4624,7 +5332,8 @@ public final class BLEManager: NSObject, ObservableObject { // change so a steady resting HR doesn't re-render the whole Live console every second. if m.hr >= 30 && m.hr <= 220, state.heartRate != m.hr { state.heartRate = m.hr } // Record it continuously — independent of the realtime stream or the open screen. - collector?.ingestStandardHR(hr: m.hr, rr: m.rr, at: Int(Date().timeIntervalSince1970)) + collector?.ingestStandardHR(hr: m.hr, rr: m.rr, contact: m.contact, + at: Int(Date().timeIntervalSince1970)) } } @@ -4768,6 +5477,36 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { advertisementData: [String: Any], rssi RSSI: NSNumber) { let name = (advertisementData[CBAdvertisementDataLocalNameKey] as? String) ?? peripheral.name ?? "unknown" + // The raw name goes to the DEVICE LIST (the user's own screen, where they need to recognise + // their strap); only the log gets the model-only form. See LiveState.logSafeDeviceName. + let safeName = LiveState.logSafeDeviceName(name) + // #1635: what the strap ADVERTISED, once per scan. The open question there is whether a + // refusing strap was in pairing mode, and a strap that accepts pairing should advertise + // differently — but nothing recorded the advertisement, so no log could say. Structure only, + // never payload: see `ScanAdvertisementSummary`. Test Centre gated. CoreBluetooth exposes no AD + // flags byte, so that field reads `none` here and carries on the Kotlin side. + if !advertisementLogged, TestCentre.active(.connection) { + advertisementLogged = true + let svc = (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID])? + .map { $0.uuidString.lowercased() } ?? [] + var svcLens: [String: Int] = [:] + if let sd = advertisementData[CBAdvertisementDataServiceDataKey] as? [CBUUID: Data] { + for (k, v) in sd { svcLens[k.uuidString.lowercased()] = v.count } + } + var mfgLens: [Int: Int] = [:] + if let md = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data, md.count >= 2 { + mfgLens[Int(md[0]) | (Int(md[1]) << 8)] = md.count - 2 + } + state.append(log: ScanAdvertisementSummary.line( + flags: nil, + serviceUuids: svc, + serviceDataLengths: svcLens, + manufacturerDataLengths: mfgLens, + txPower: (advertisementData[CBAdvertisementDataTxPowerLevelKey] as? NSNumber)?.intValue, + localNameLength: ScanAdvertisementSummary.localNameLength( + advertisementData[CBAdvertisementDataLocalNameKey] as? String), + connectable: (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? true)) + } // #716: the seeded "my-whoop" device has model "WHOOP" (no generation). Once a live scan // confirms which service family the strap advertises, stamp the correct model so // forRegistryModel returns the right DeviceFamily (fixes skin-temp ADC scale + display). @@ -4786,10 +5525,10 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { ) if !scanDecision.shouldConnect { if let family = scanDecision.unsupportedFamily { - log("Discovered \(name) (rssi \(RSSI)) — \(family.diagnosticUnsupportedMessage)") + log("Discovered \(safeName) (rssi \(RSSI)) — \(family.diagnosticUnsupportedMessage)") return } - log("Discovered \(name) (rssi \(RSSI)) without \(selectedModel.displayName) service — ignoring") + log("Discovered \(safeName) (rssi \(RSSI)) without \(selectedModel.displayName) service — ignoring") return } // Multi-WHOOP present-scan (Add-a-WHOOP wizard): collect the strap, do NOT auto-connect, and @@ -4810,12 +5549,14 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { // WHOOP default) this guard is skipped and the original "connect to the first discovered" path // below is byte-for-byte unchanged. if let preferred = preferredPeripheralUUID, peripheral.identifier != preferred { - log("Discovered \(name) (\(peripheral.identifier)) — not the preferred strap; ignoring") + log("Discovered \(safeName) (\(peripheral.identifier)) — not the preferred strap; ignoring") return } + // No gate here for the same reason as `startScan`: reaching a discovery means a scan is running, + // and a scan only runs because the user asked or because the gated system entry allowed it. cancelScanFallback() persistSelectedModel(selectedModel) - log("Discovered \(name) (rssi \(RSSI)) — connecting") + log("Discovered \(safeName) (rssi \(RSSI)) — connecting") central.stopScan() preparePeripheral(peripheral) central.connect(peripheral, options: nil) @@ -4825,9 +5566,21 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { cancelScanFallback() cancelPendingConnectProbe() // #730: the connect resolved; no pending-connect log needed failedConnectAttempts = 0 // a successful connect clears the reconnect backoff (#414) + // #1809: this link's inbound tally starts empty; the epitaph on disconnect reports exactly what + // arrived between here and there. + inboundFrames = 0; inboundBytes = 0; cmdChannelFrames = 0 + // #1635: same guarantee for the banked tally. Clearing only on teardown would be enough if every + // link ended in one, and a link that begins without a preceding clean teardown would otherwise + // open holding the previous link's rows — reporting them as banked on a link that never saw them. + liveHr = 0; liveRr = 0; offloadHr = 0; offloadRr = 0 + offloadGravity = 0; offloadResp = 0; offloadSkinTemp = 0; offloadSpo2 = 0; offloadChunks = 0 + linkUpSince = DispatchTime.now() standingConnectAt = nil // #1413: a live link means no standing connect is outstanding restoredPeripheral = nil preparePeripheral(peripheral) + // #1881: BEFORE anything persists. The Collector and Backfiller read `deviceId` at flush and at + // finishChunk, so re-pointing here is what keeps this link's rows off another device's id. + adoptSourceIdentity(for: peripheral) // Clear the per-connection bond BEFORE publishing the connected uuid below. SourceCoordinator's #52 // re-adoption gate keys off `encryptedBond` at the instant `connectedPeripheralUUID` is observed — // an ordinary `didConnect` publish must always read false (only the deliberate post-bond #52 @@ -4890,6 +5643,53 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { return "code?" } + /// Feed one 5/MG bond refusal into the #747 give-up and act on the trip (#1635). + /// + /// Both causes arrive here, and they want OPPOSITE treatment — see `giveUpSuppressesHello`. An auth + /// refusal means the strap actively declined, so pausing auto-reconnect is right. An unanswered + /// handshake means the write vanished while the link streamed live HR the whole time, so pausing + /// would throw away a working strap to punish a handshake nobody is waiting on. + /// + /// Shared by both call sites so the split cannot drift: the auth path (a write error) and the + /// unanswered path (a disconnect with a hello still outstanding) reach the same decision. + private func recordWhoop5BondRefusal(authRefusal: Bool, peripheralUUID: String) { + // #1635: the two causes want different patience, for the same reason they want different + // outcomes - see `giveUpThresholdFor`. Keyed on the same authRefusal as the branch below, so the + // threshold and the treatment can never disagree about which kind of refusal this is. + guard bondGiveUp.recordRefusal( + threshold: giveUpThresholdFor(authRefusal: authRefusal, pauseThreshold: bondGiveUp.giveUpThreshold) + ) else { return } + let opaque = BondRefusalGiveUp.opaqueId(fromLocalUUID: peripheralUUID) + let suppress = giveUpSuppressesHello(authRefusal: authRefusal) + if suppress { + HelloSuppressionStore.setSuppressed(peripheralUUID, true) + log(BondRefusalGiveUp.helloSuppressedEpitaph(refusals: bondGiveUp.refusals, opaqueId: opaque)) + } else { + autoReconnectPausedForBondLoop = true + bondLoopPausedAt = Date() // starts the #78 hole-4 salvage-probe floor + // #1539: park the connect in the same breath as the pause, so this can end while backgrounded. + standingConnectWhilePausedIfDue(justTripped: true) + log(BondRefusalGiveUp.epitaphLine(refusals: bondGiveUp.refusals, opaqueId: opaque)) + } + // Each branch gets the hint that matches what it actually DID. The paused hints say "auto-reconnect + // is paused"; on the suppression branch nothing is paused, so saying so would be the same + // confidently-wrong diagnostic this issue has produced twice already (#1635). The third arm is + // unreachable while `giveUpSuppressesHello` is `!authRefusal`, and is kept because the Kotlin twin + // keeps it: if that rule ever changes, both platforms must change behaviour together. + if suppress { + state.pairingHint = BondRefusalGiveUp.helloSuppressedHint() + } else if authRefusal { + state.pairingHint = BondRefusalGiveUp.pausedHint() + } else { + state.pairingHint = BondRefusalGiveUp.pausedHintHandshakeUnanswered() + } + if TestCentre.active(.connection) { + state.append(log: "bond gaveUp refusals=\(bondGiveUp.refusals) id=\(opaque) " + + (suppress ? "(hello suppressed, staying connected)" : "(auto-reconnect paused)"), + domain: .connection) + } + } + public func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { @@ -4915,6 +5715,50 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { // arm timestamp. A drop that is unintentional, error-bearing, and lands shortly after we armed // the R10/R11 burst is the marginal-radio tell. Feed the detector; if it trips, the NEXT connect // skips the heavy arm (the flag is intentionally NOT reset on disconnect so it survives rescan). + // #1809: the epitaph goes out BEFORE the resets below, for the same reason the two detectors read + // their state here - `realtimeArmedAt` is about to be cleared. `ended` carries the CBError CASE, not + // just the OS sentence: the #617 branch already computes that code and the strap log never saw it. + let endedReason: String + if intentionalDisconnect { + endedReason = "intentional" + } else if let cb = error as? CBError { + endedReason = "CBError.\(cb.code)(\(cb.code.rawValue))" + } else if let error { + endedReason = "\(error)" + } else { + endedReason = "no error reported" + } + // Only for a link we actually held. Emitting without one would report "the strap sent NOTHING on + // this link" using the PREVIOUS link's counters, fabricating the very symptom #1809 is about. + if let since = linkUpSince { + let upMs = Int(Double(DispatchTime.now().uptimeNanoseconds &- since.uptimeNanoseconds) / 1_000_000) + log(ConnectionReadout.linkEpitaph(upMillis: upMs, inboundFrames: inboundFrames, + inboundBytes: inboundBytes, cmdChannelFrames: cmdChannelFrames, + realtimeArmed: realtimeArmedAt != nil, ended: endedReason)) + // #1635: LIVE streams only — the offload persists through `Backfiller` and has its own + // accounting, so folding it in would make a healthy bonded sync read as "nothing banked live + // for: gravity". Inside the same `linkUpSince` guard for the same reason the epitaph is. + log(ConnectionReadout.linkBankedSummary( + liveHr: liveHr, liveRr: liveRr, offloadChunks: offloadChunks, + offloadHr: offloadHr, offloadRr: offloadRr, offloadGravity: offloadGravity, + offloadResp: offloadResp, offloadSkinTemp: offloadSkinTemp, offloadSpo2: offloadSpo2, + // nil, not 0: this store does not return a step count, and a zero would read as a fault. + offloadSteps: nil)) + // Frames this link REJECTED, per reason. A per-connection readout, so it sits behind the + // Test Centre's Connection domain (D3) — a resync after a lost notification rejects frames + // routinely and always did, and that number is explicitly NOT the signal to act on. The one + // that is (payload CRC32 verified, envelope not) announces itself when it happens, whether + // or not any test mode is on. Silent when the link rejected nothing. + if TestCentre.active(.connection), let rejects = router.rejectTally.summaryLine() { + state.append(log: rejects, domain: .connection) + } + } + // Clear the tally with the link, so a second teardown for the same drop cannot re-report it. + inboundFrames = 0; inboundBytes = 0; cmdChannelFrames = 0 + liveHr = 0; liveRr = 0; offloadHr = 0; offloadRr = 0 + offloadGravity = 0; offloadResp = 0; offloadSkinTemp = 0; offloadSpo2 = 0; offloadChunks = 0 + linkUpSince = nil + let timedOut = !intentionalDisconnect && error != nil let sinceArm = realtimeArmedAt.map { Date().timeIntervalSince($0) } if marginalRadio.connectionEnded(wasArmed: realtimeArmedAt != nil, @@ -5007,9 +5851,24 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { } state.clearBiometrics() // and a stale HR / R-R must not outlive the link either state.liveFeedActive = false // a drop while Live is open must not leave a stale "Stop live feed" + // #1635: an unanswered CLIENT_HELLO produces no write error at all - the link simply drops a few + // seconds later - so it never reached the give-up on this platform and nothing could end the loop. + // Read it HERE, while `clientHelloWriteAt` and `didBond` are both still valid, and feed it in + // through the same split the auth path uses. `countsAsBondRefusal` gates on family, so a 4.0 (which + // bonds cleanly) can never latch the suppression. + if countsAsBondRefusal(isAuthRefusalStatus: false, + helloUnacked: clientHelloWriteAt != nil, + alreadyBonded: didBond, + family: selectedModel.deviceFamily) { + recordWhoop5BondRefusal(authRefusal: false, + peripheralUUID: peripheral.identifier.uuidString) + } didBond = false clientHelloWriteAt = nil // #1635: no hello survives the link that carried it whoop5RealtimeArmed = false + // A user capture remains represented by RawDataSessionStore, but its transport must be re-armed + // on the next connection. Keeping this true would make that reconnect attempt a silent no-op. + rawCaptureInFlight = false // The strap forgets the realtime-HR toggle across a disconnect; the post-bond branch re-arms it // from `wantsRealtime`. Clear only the "what we last sent" flag — `screenWantsRealtime` / // `keepRealtimeForData` (and thus `wantsRealtime`) are intent and must survive a reconnect so the @@ -5019,6 +5878,9 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { clockRequested = false clockRetries = 0 connectHandshakeDone = false + // Mirrors the flag above: a new link has not done the handshake, so it cannot hand over history + // until it does. Cleared HERE and nowhere else, so the two can never disagree. + state.historyReady = false cmdNotifyConfirmedActive = false // #34: a fresh connection needs its own notify-confirm + settle connectSettledSignaled = false restoreNeedsResubscribe = false // #613: a real reconnect isn't a restore — never force-toggle here @@ -5032,6 +5894,7 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { lastSessionEndTrim = nil backfilling = false state.backfilling = false + state.historyPendingSync = false // #1164: a stale "pending" must not outlive the link state.syncChunksThisSession = 0 // A mid-sync disconnect bypasses exitBackfilling, so clear the reject counters here too — // otherwise a stale non-zero count survives until the next beginBackfill. (#77/#91) @@ -5068,7 +5931,7 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { Task { @MainActor in await puffinRecorder.flush() } // persist any buffered puffin capture frames puffinEventLog.close() // release the event-log handle so the file is safe to export puffinDeepBufferLog.close() // same for the high-rate deep-buffer log (#423) - Task { @MainActor in await collector?.flushStandardHR() } // persist any buffered 0x2A37 HR + Task { @MainActor in await collector?.flushStandardHR(reason: .disconnect) } // persist any buffered 0x2A37 HR if autoReconnectPausedForBondLoop { // #747: the bond keeps being refused, so auto-reconnect is paused: we stop hammering a strap that // can't bond (the epitaph + paused hint were already surfaced when the give-up tripped). The user @@ -5133,6 +5996,22 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { } state.append(log: "reconnect n=\(connReconnectCount) failedConnect reason=\(reason)", domain: .connection) } + // PARITY (#1790): Android can now CLEAR this pairing itself, behind a default-off Test Centre + // switch, once, after five of these failures — the step guide-line 2 asks the user to take. This + // side deliberately cannot, and the blocker is identity rather than a missing call: + // + // - iOS: the same identity problem, with nothing else to reach for. The guide below already + // tells the user to do it themselves, which is the honest answer rather than a claim about + // Apple's intent. + // - macOS ships IOBluetooth, a separate classic-Bluetooth framework, and whether its device + // removal works on a BLE pairing is a question that never arises for us: it addresses devices + // by MAC, and we never have one. `epitaphLine`'s doc already says why — the peripheral UUID is + // "per-install, not the hardware address". The only bridge left is matching + // `IOBluetoothDevice.pairedDevices()` by NAME, and deleting a pairing on a name match removes + // the wrong device the moment there are two straps or one has been renamed. + // + // So both platforms detect this and both give the same advice; only Android can act on it. Do not + // close the gap with a name match. Kotlin twin: `StaleBondRemoval.kt`. if let cbErr = error as? CBError, cbErr.code == .peerRemovedPairingInformation { state.reconnectGuide = """ Your strap's Bluetooth pairing was reset - usually by a WHOOP firmware update, or the official WHOOP app reconnecting. NOOP works fine on the new firmware; you just need to re-pair: @@ -5185,6 +6064,7 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { selectedModel = .persisted reassembler = Reassembler(family: selectedModel.deviceFamily) router.family = selectedModel.deviceFamily + router.deviceId = deviceId // #1706: attribute this connection's alarm readback configureCollectorFamily() // Collection only runs post-bond, so a restored link was already bonded; // seed those flags now. `didWriteValueFor` won't re-fire on its own. @@ -5204,7 +6084,13 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { clockRequested = false clockRetries = 0 // Ensure the store is ready before restored BLE data arrives (idempotent; no-op if already built). - Task { @MainActor in await bootstrapStore() } + Task { @MainActor in + await bootstrapStore() + // #1881: `didConnect` never fires on this path (see above), so the identity adoption that lives + // there would be skipped for a restored link — the very path a radio toggle and a relaunch take. + // After `bootstrapStore`, because it is what creates `registryStore`. + if let p = self.peripheral ?? self.restoredPeripheral { self.adoptSourceIdentity(for: p) } + } if p.state == .connected { state.connected = true // #613: the inherited notify subscriptions come back reported-active but dead. Force one real @@ -5274,9 +6160,11 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { case BLEManager.batteryService: peripheral.discoverCharacteristics([BLEManager.batteryChar], for: s) case BLEManager.disService: - // #520: read-only identity strings. Discovered here, but READ post-bond (below). + // #520: read-only identity strings. Discovered here; READ post-bond, or on a suppressed + // link via `readDisIdentityUnbonded` (#1635) — a strap that never bonds has no other + // source for its firmware at all. peripheral.discoverCharacteristics( - [BLEManager.disSerialChar, BLEManager.disHwRevChar], for: s) + BLEManager.disChars.map { $0 }, for: s) case BLEManager.whoop5Service: // EXPERIMENTAL WHOOP 5.0/MG path: discover the puffin command + notify characteristics // so we can send CLIENT_HELLO and receive frames. Live HR/battery still arrive over the @@ -5337,7 +6225,25 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { // fires for a 5/MG strap. Live HR/battery come from the standard profiles; this just // opens the puffin session. Unverified on real MG hardware. cmdCharacteristic = c - if let hello = selectedModel.deviceFamily.clientHello { + // #1635: once the give-up has latched, the hello is what ends the link — skip it and let + // the standard-profile HR stream keep running. Consumed unconditionally so an explicit + // Connect's single retry belongs to this session and cannot leak into a later automatic one. + let helloUserAsked = helloRetryRequested + helloRetryRequested = false + let helloSuppressed = HelloSuppressionStore.suppressed(peripheral.identifier.uuidString) + if !shouldSendClientHello(suppressedForDevice: helloSuppressed, userInitiated: helloUserAsked) { + // #1635: says what happened and what has actually worked, not "try again". This strap + // refuses the handshake, so a retry is the one thing that cannot help - and suggesting + // it invites the hammering this suppression exists to stop. Mirrors the user-facing + // hint, which lost the same ending. + log("WHOOP 5/MG: CLIENT_HELLO suppressed for this strap - it was never acknowledged and the write is what drops the link. Staying on live HR (not fully paired). Some straps have paired again after being put in pairing mode (tap until the LEDs flash blue) (#1635).") + state.pairingHint = BondRefusalGiveUp.helloSuppressedHint() + // The unbonded DIS attempt rides HERE and nowhere else: this is the only 5/MG state + // known to be stable - the handshake is off and the link is holding - and it is now + // PERMANENT rather than transient, so without it a suppressed strap never learns its + // firmware or whether it is an MG at all (#490). + scheduleUnbondedDisRead() + } else if let hello = selectedModel.deviceFamily.clientHello { // CONTRIBUTOR FIX (issue #17 — diagnosed from the logs, unverified on hardware here): // write CLIENT_HELLO with .withResponse so CoreBluetooth runs just-works bonding when // the link needs authenticating, AND so didWriteValueFor fires. That callback is where @@ -5370,13 +6276,22 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { default: break } requestNotify(c, on: peripheral, reason: "discovery") - case BLEManager.disSerialChar, BLEManager.disHwRevChar: - // #520: CAPTURE ONLY — the read is issued post-bond (a 5/MG refuses standard reads on an - // unencrypted link, see #490). These are read-only identity strings: never subscribed - // (no notify property) and never written. Must be handled BEFORE `default`, or they'd be - // retained as puffin notify characteristics. + case BLEManager.disSerialChar, BLEManager.disHwRevChar, BLEManager.disFwRevChar, + BLEManager.disManufacturerChar, BLEManager.disModelNumberChar, BLEManager.disSwRevChar: + // #520: CAPTURE ONLY — the read is issued post-bond, and since #1635 ALSO on a suppressed + // link that will never bond (see `readDisIdentityUnbonded`; whether a 5/MG answers a + // standard read unencrypted is the open question #490 asks). These are read-only identity + // strings: never subscribed (no notify property) and never written. Must be handled BEFORE + // `default`, or they'd be retained as puffin notify characteristics. if c.uuid == BLEManager.disSerialChar { disSerialCharacteristic = c + } else if c.uuid != BLEManager.disHwRevChar { + // The identity extras are read straight off the discovered service, so they need no + // dedicated property — but they must still be claimed here or `default` would retain + // them as puffin notify characteristics. + if !disExtraCharacteristics.contains(where: { $0.uuid == c.uuid }) { + disExtraCharacteristics.append(c) + } } else { disHwRevCharacteristic = c } @@ -5442,19 +6357,11 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { // threshold (the pairing hint has had several cycles to be acted on), pause auto-reconnect so // we stop hammering a strap that can't bond, write the one-line epitaph (opaque id only, no // PII), and surface the honest paused hint. A genuine bond or a manual reconnect re-arms it. - if bondGiveUp.recordRefusal() { - autoReconnectPausedForBondLoop = true - bondLoopPausedAt = Date() // starts the #78 hole-4 salvage-probe floor - // #1539: same reasoning as the #617 trip — park a connect now so this pause can end - // while the app is backgrounded, not only when the user next opens it. - standingConnectWhilePausedIfDue(justTripped: true) - let opaque = BondRefusalGiveUp.opaqueId(fromLocalUUID: peripheral.identifier.uuidString) - log(BondRefusalGiveUp.epitaphLine(refusals: bondGiveUp.refusals, opaqueId: opaque)) - state.pairingHint = BondRefusalGiveUp.pausedHint() - if TestCentre.active(.connection) { - state.append(log: "bond gaveUp refusals=\(bondGiveUp.refusals) id=\(opaque) (auto-reconnect paused)", domain: .connection) - } - } + // #1635: the trip itself now lives in recordWhoop5BondRefusal, shared with the + // unanswered-handshake path at disconnect, so the two causes cannot drift apart. This + // one is an AUTH refusal — the strap actively declined — so it still pauses. + recordWhoop5BondRefusal(authRefusal: true, + peripheralUUID: peripheral.identifier.uuidString) } // Multi-WHOOP stale-pin recovery (#52). When a stale registry pin points at a strap that keeps // refusing the encrypted bond ("Encryption/Authentication is insufficient") but a DIFFERENT @@ -5486,6 +6393,11 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { // genuinely up keeps its notifications either way. let isHelloChar = characteristic.uuid == BLEManager.whoop5CmdWriteChar let helloOutstanding = clientHelloWriteAt != nil + // #1883: compute the elapsed time BEFORE clearing the window, so the timing tell can use it. + // The elapsed time is the whole signal Apple has — CoreBluetooth exposes no bond state, so + // a completion faster than one connection interval is the one tell that the callback came + // from the local stack rather than the strap (#1635). + let helloElapsedMs = clientHelloWriteAt.map { Int(Date().timeIntervalSince($0) * 1000) } // Consume the window ONLY for the hello's own completion. A foreign completion that cleared it // would make a genuine ack arriving afterwards look unsolicited, costing a real bond. // @@ -5505,6 +6417,10 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { isWhoop5: true ) { didBond = true + // #1635: the handshake demonstrably works on this strap - drop the latch so a later + // transient failure starts from a clean slate rather than inheriting an old verdict. + HelloSuppressionStore.setSuppressed(peripheral.identifier.uuidString, false) + bondGiveUp.reset() state.bonded = true state.encryptedBond = true // genuine encrypted bond (not the live-HR shortcut) — #69 bondedAt = Date() // #617: start the bond→drop stopwatch for the bond-loop detector @@ -5516,6 +6432,14 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { noteGenuineBond(of: peripheral) // #52: this strap bonds fine; clears any pin-refusal streak emitConnectionBondState("encryptedBond family=whoop5 (CLIENT_HELLO acked)") log("WHOOP 5/MG: CLIENT_HELLO acked — link established; subscribing notify chars (experimental).") + // #1883: Apple has no link-encryption state to verify the bond against. A completion + // faster than one connection interval did not come from the strap — that is the + // signature of #1635's false bond. Log it as UNVERIFIED without changing behavior, + // because on Apple there is no alternative source of truth and refusing to bond would + // break every strap that genuinely bonds. + if let elapsed = helloElapsedMs, let timingLine = ClientHelloOutcome.unverifiedBondTimingLine(elapsedMs: elapsed) { + log(timingLine) + } } for c in whoop5NotifyCharacteristics where !c.isNotifying || restoreNeedsResubscribe { requestNotify(c, on: peripheral, reason: "post-bond puffin") // #613: force re-arm on restore @@ -5545,6 +6469,7 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { if !whoop5SessionStarted { whoop5SessionStarted = true connectHandshakeDone = true // unblocks beginBackfill()'s guard + state.historyReady = true // and the sync controls, which must not offer what it declines log("WHOOP 5/MG: connect handshake done — backfill unblocked") noteRebootReconnectIfNeeded() // Re-apply the Broadcast-HR device-config flag if the user opted in (#181). @@ -5557,7 +6482,11 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { // Hardware-validated ordering (#78 fork). send(.setClock, payload: BLEManager.setClockPayload()) send(.getClock, payload: []) - log("WHOOP 5/MG: clock synced (set/get) — strap can persist history now") + // #1823: say what was SENT, not what resulted. This previously read "clock synced — + // strap can persist history now", logged before any reply had arrived, so a log could + // assert the clock was set while the Devices readout said 1970/71. The strap's actual + // answer now arrives as the "clock: …" ack line from FrameRouter. + log("WHOOP 5/MG: SET_CLOCK + GET_CLOCK sent — awaiting the strap's answer") log("WHOOP 5/MG: scheduling first historical offload (connect)") // Deferred ~1.5s so the puffin notify subscriptions settle before SEND_HISTORICAL_DATA, // mirroring the WHOOP4 kick. requestSync → beginBackfill is itself gated on @@ -5594,6 +6523,7 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { // type-47 fine because it runs the sequence once on a stable connection; the app stormed it. guard !connectHandshakeDone else { return } connectHandshakeDone = true + state.historyReady = true noteRebootReconnectIfNeeded() backfillStarted = true @@ -5763,11 +6693,19 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { // log was indistinguishable from one where the strap never answered, which is why a broken decode // survived unnoticed. The raw-frame dump above is unconditional for the same reason. If a firmware // 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)") - } 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.") + // ...but NOT on the PENDING(2) ack. GET_DATA_RANGE answers twice — a short ack, then the payload — + // which Framing's result-code table already records ("2=PENDING precedes SUCCESS on + // GET_DATA_RANGE"). The ack carries no ring pointers at all, so decoding it failed every time and + // reported a decode problem that did not exist: one "offsets may have moved" per sync, on healthy + // hardware, pointing the reader at an alignment bug rather than at the ack. Skip it here; the + // SUCCESS frame still logs its failure loudly, which is the case the paragraph above protects. + if !DataRange.isPendingResponse(frame, cmdOff: cmdOff) { + if let pages = DataRange.pagesBehind(from: frame, cmdOff: cmdOff) { + log("Strap backlog pages behind: \(pages) (#689 — GET_DATA_RANGE ring backlog, diagnostic only)") + } 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.") + } } if let newest = BLEManager.dataRangeNewestUnix(from: frame) { // #695: the sync-affecting side-effects (strapNewestTs, backfill window, LiveState range) only @@ -5799,6 +6737,27 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { // UNIVERSAL clock-drift snapshot (RTC cluster #531/#767/#804/#812): bank the [oldest, newest] // window onto LiveState UNCONDITIONALLY (observability, not gated) for the export assembler. if feedsSync { state.setStrapRange(newestUnix: newest, oldestUnix: (oldest.map { $0 < newest } ?? false) ? oldest : nil) } + // #1164: recompute the "strap has banked records newer than our frontier" flag so the Today + // Rest card can show "Pending sync" right after connect (before the first offload starts), + // not only after an offload completes. The frontier read is async; the flag settles a beat + // after the range lands. feedsSync only (5/MG sync is unconfirmed — leave it untouched). + if feedsSync { + let newestForPending = newest + Task { @MainActor in + let frontier = await collector?.latestHRSampleTs() ?? nil + if let f = frontier { + // #928/#1012: a strap whose clock is set in the FUTURE reads ahead of ANY + // frontier, so without this the gap never closes and Rest is pinned to "Pending + // sync" for good. The phantom-gap guard used at the post-offload site cannot apply + // here: no offload has run yet, so there is no row evidence to weigh. The first + // completed pass corrects it. + let wallNowP = Int(Date().timeIntervalSince1970) + state.historyPendingSync = + !BackfillContinuation.isFutureDatedNewest(newestForPending, wallNowUnix: wallNowP) + && (newestForPending - f) > BackfillContinuation.defaultBehindGapSeconds + } + } + } // Connection test mode: promote the CLOCK-DRIFT picture to one upfront tagged line (#767/#754). if TestCentre.active(.connection) { let line = ConnectionTrace.clockDriftLine( @@ -5814,12 +6773,20 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { if let error { + // A DIS refusal is a FINDING, not noise — report it specifically and latch it, or a capture + // cannot tell a refusal from a read that was never issued (#490, #1635). + noteDisReadFailure(characteristic.uuid, error) log("Notify update failed for \(characteristic.uuid): \(error.localizedDescription)") return } guard let data = characteristic.value else { return } let bytes = [UInt8](data) lastDataAt = Date() // feed the liveness watchdog on every notification + // #1809: count BEFORE the per-characteristic switch below, so the tally covers every inbound + // frame including ones no branch consumes - the epitaph must answer "did anything arrive at all". + inboundFrames += 1 + inboundBytes += bytes.count + if characteristic.uuid == BLEManager.cmdNotifyChar { cmdChannelFrames += 1 } switch characteristic.uuid { case BLEManager.heartRateChar: @@ -5830,6 +6797,11 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { if selectedModel.deviceFamily == .whoop5, !state.bonded { state.bonded = true log("WHOOP 5/MG: live HR streaming — marking the link established (experimental).") + // #1635: a 5/MG has no confirmed-write handshake, so the keep-alive (and with it the + // liveness watchdog) is started HERE, on the bonded transition, exactly as the Kotlin twin + // does. The other two start sites are post-bond, which a suppressed strap never reaches — + // and this also covers an unbonded 5/MG that has not latched yet. + startKeepAlive() } case BLEManager.batteryChar: // 0x2A19 = percent — 5/MG ONLY. The WHOOP 4.0's 0x2A19 is a stub constant 100 (real value = @@ -5847,20 +6819,65 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { disHwRev = String(decoding: bytes, as: UTF8.self) .trimmingCharacters(in: CharacterSet(charactersIn: "\0").union(.whitespacesAndNewlines)) noteWhoop5VariantFromDIS() + case BLEManager.disFwRevChar: + disFirmware = String(decoding: bytes, as: UTF8.self) + .trimmingCharacters(in: CharacterSet(charactersIn: "\0").union(.whitespacesAndNewlines)) + // FALLBACK only. The puffin report is the strap's own account of what it is running and lands + // later in the connect; a value that appeared and then changed would be worse than one that + // arrived once, so this yields rather than racing it. + if shouldPublishDisFirmware(disFirmware: disFirmware, alreadyDecoded: state.strapFirmware) { + state.strapFirmware = disFirmware + // #1634: persist it against THIS strap. The per-device key has readers (DevicesView, the + // debug export) but had no writer at all - the decoded path cannot key on identity, which + // its own comment concedes - so `perDevice` always resolved nil and iOS fell back to a + // global value that is wrong the moment a second strap exists. This site DOES have the + // peripheral, so it is the one place that can attribute a firmware honestly. Kotlin twin: + // NoopPrefs.setFirmwareFor beside the same publish. + if let key = FirmwareAttribution.prefKey(peripheralId: peripheral.identifier.uuidString), + let fw = disFirmware { + UserDefaults.standard.set(fw, forKey: key) + } + // Named as the DIS source, because it is not the same reading as the decoded one a 4.0 + // shows and a capture must not have to guess which it is looking at. + log("DIS: firmware=\(disFirmware ?? "?") (standard profile, no bond required)") + } else { + let shown = (disFirmware?.isEmpty ?? true) ? "?" : (disFirmware ?? "?") + log("DIS: firmware=\(shown) not published — a decoded value already stands") + } + case BLEManager.disManufacturerChar, BLEManager.disModelNumberChar, BLEManager.disSwRevChar: + // Diagnostic only: nothing gates on these, but they cost one read each and are exactly what + // is missing when someone reports an unidentified strap. + let v = String(decoding: bytes, as: UTF8.self) + .trimmingCharacters(in: CharacterSet(charactersIn: "\0").union(.whitespacesAndNewlines)) + if !v.isEmpty { log("DIS: \(characteristic.uuid.uuidString) = \(v)") } case BLEManager.dataNotifyChar, BLEManager.cmdNotifyChar, BLEManager.eventNotifyChar: // Reassemble (no-op for already-complete frames) then route each complete frame. - for frame in reassembler.feed(bytes) { + // A byte run whose declared total is below the family minimum is dropped INSIDE the + // reassembler and reaches no parser and no archive, so its monotonic drop count is folded + // into the connection's reject tally right after the feed that may have grown it — + // otherwise it would disappear without trace. + let completedFrames = reassembler.feed(bytes) + router.noteReassemblerDrops(reassembler.belowMinimumLengthDrops) + for frame in completedFrames { if backfilling, BLEManager.isOffloadFrame(frame, family: .whoop4) { // Historical replay is bulk sync traffic, not live UI traffic. Feed it only to // the Backfiller; parsing every record through FrameRouter updates SwiftUI for // no user-visible benefit and can make the app feel hung during long offloads. + // …but the CONNECTION'S REJECT TALLY must still see it (D3): the router is what + // counts rejections, and skipping it left the one counter the hardware run's abort + // criterion reads blind in exactly the traffic where an emptied-then-acked section + // is a permanent loss. The verdict is formed once here, from the verifier, and + // handed to the counter — no parse on this path, which is why it was skipped. + router.noteOffloadFrameVerdict(verifyFrame(frame, family: .whoop4)) armBackfillTimeout() routeBackfillFrame(frame) // …but a REAL-TIME physical gesture (double-tap / wrist) must still fire even mid- // offload (#69). Gated on ts≈now so replayed historical EVENTs (old ts) are ignored. router.dispatchLiveGestureIfFresh(frame: frame, now: strapClockNow) + // …and the strap's own console narration, which it emits precisely DURING a sync. + router.mirrorStrapConsoleIfPresent(frame: frame) continue } // #47: decode this live WHOOP4 frame ONCE here and thread the result to every consumer @@ -5872,6 +6889,30 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { // the router + collector re-checks the invariant). let parsed = parseFrame(frame, family: .whoop4) router.handle(parsed: parsed, frame: frame) // live/UI path + // + // WHAT IS AND IS NOT GATED BELOW (standing risk, recorded rather than fixed here). + // + // Everything from here on branches on a RAW BYTE COMPARE of the command opcode, before + // any integrity verdict. That is deliberate and unchanged by the frame-integrity work: + // the six state-driving consumers are gated (router, history-metadata classifier, both + // extractors, clock correlation, and the data-range reply below), and this change does + // not claim "every frame consumer". + // + // Of the probe replies dispatched here, these verify before reading a field, inside + // their own decoder: the feature-flag probe and the device-config READ probe (both + // through `verifyFrame` in WhoopProtocol), the R22 read-back and the ECG/Broadcast-HR + // gate READ-BACKS (through the same probe parser), and the 5/MG ECG probe. + // + // These do NOT, and stay that way for now — none drives live state or the offload: + // • the extended-battery probe (#592) and the body-location probe (#690): each reads + // the reply's bytes, states a finding about the strap in the Devices dialog, and + // persists its payload hex to UserDefaults for the next capture diff; + // • the WRITE-ACK branches (R22 disable, ECG gate, Broadcast-HR gate), which read a + // result code straight out of the frame for their transcripts. + // The exposure is a diagnostic that asserts something the strap never said — a real + // violation of "a diagnostic may only assert what it can attribute", but a reading, not + // a data path. Hardening them is a scope decision, not this change's. + // // #592: the read-only extended-battery probe's COMMAND_RESPONSE — format + publish it for the // Devices dialog (raw hex + payload triage + capture diff). Sibling of the #451 dump below. if frame.count > 6, frame[6] == WhoopCommand.getExtendedBatteryInfo.rawValue { @@ -5898,7 +6939,17 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { // #695: WHOOP4 data-range reply — cmd byte @6. The 5/MG reply (puffin envelope, cmd @10) is // handled in the 5/MG case below; both call handleDataRangeResponse so 5/MG now gets the same // newest/oldest window (strapNewestTs / #547 backfill gate) + diagnostics it previously missed. - if frame.count > 6, frame[6] == WhoopCommand.getDataRange.rawValue { + // + // GATED ON THE FULL VERDICT, unlike the probe replies above, because this one has OFFLOAD + // effect: the newest/oldest words it yields become the plausibility window every drained + // record is checked against (#547). A damaged reply that narrows that window makes the + // real records of the same sync fall through it — the section then persists nothing, and + // is acknowledged anyway, which is the same permanent loss the integrity gate exists to + // stop. `parsed` is the single parse from the seam above; this costs one Bool, no reparse. + // The decision itself lives in `DataRange.acceptsReply` so a WhoopProtocol test can hold + // it — nothing compiles this seam, so a gate written only here is a gate nothing pins. + if DataRange.acceptsReply(frame, cmdOff: 6, opcode: WhoopCommand.getDataRange.rawValue, + verdictOK: parsed.ok) { handleDataRangeResponse(frame, cmdOff: 6, feedsSync: true) } // Clock correlation runs in both live and backfill modes. Once established it @@ -5934,7 +6985,10 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { // timestamps are already real-unix seconds.) Live HR/battery still also come from the // standard 0x2A37 / 0x2A19 profiles handled above. if BLEManager.whoop5NotifyChars.contains(characteristic.uuid) { - for frame in reassembler.feed(bytes) { + // Same fold as the WHOOP 4.0 path above: byte runs dropped below the family minimum. + let completedFrames = reassembler.feed(bytes) + router.noteReassemblerDrops(reassembler.belowMinimumLengthDrops) + for frame in completedFrames { let isOffload = backfilling && BLEManager.isOffloadFrame(frame, family: .whoop5) noteWhoop5R22Telemetry(frame, duringOffload: isOffload) // #174 deep-data telemetry // Durable EVENT-frame log for deep-data research (#103) — BEFORE the offload @@ -5946,21 +7000,30 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { // reverse-engineering — its own file the bulk-capture eviction never churns. // BEFORE the offload branch so it catches the burst; no-op unless capture is on. puffinDeepBufferLog.appendIfDeepBuffer(frame: frame, char: characteristic.uuid, isOffload: isOffload) + stopUnexpectedRealtimeImu(frame, isOffload: isOffload) // #423: the queryable twin of that diagnostics line — persist the decoded 100 Hz 6-axis - // IMU samples into the rawImuSample table when raw capture is on (same gate, gated inside). - collector?.storeRawImu(frame: frame) if isOffload { // Same policy as WHOOP4: historical offload frames are bulk sync traffic. // Keep them out of the live UI parser during backfill and let Backfiller // preserve/order/process them in the sliced drain. + // Same tally hand-off as the WHOOP 4.0 loop, and for the same reason (D3): + // the router counts rejections, so a frame that skips it would be invisible + // to the counter the hardware run is judged on. One verdict, no parse. + router.noteOffloadFrameVerdict(verifyFrame(frame, family: .whoop5)) armBackfillTimeout() routeBackfillFrame(frame) // A real-time double-tap / wrist gesture still fires during a 5/MG offload (which // runs for minutes, #69); the ts≈now gate rejects replayed historical EVENTs. router.dispatchLiveGestureIfFresh(frame: frame, now: strapClockNow) + // …and the strap's own console narration, which it emits precisely DURING a sync. + router.mirrorStrapConsoleIfPresent(frame: frame) continue } router.handle(frame: frame) + // The same split as the WHOOP 4.0 loop above, and for the same reasons: the probe + // dispatches below branch on a raw opcode compare; the ones that verify do it inside + // their own decoder, the extended-battery / body-location probes and the write-ack + // branches do not, and are recorded there as a standing risk rather than hardened. // #592: a 5/MG extended-battery probe COMMAND_RESPONSE (puffin envelope: type @8, cmd // @10). Format + publish it for the Devices dialog, exactly like the 4.0 path above. if frame.count > 10, frame[8] == 0x24, frame[10] == WhoopCommand.getExtendedBatteryInfo.rawValue { @@ -6014,7 +7077,14 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { // the ECG records arrive under. `ecgProbeArmed` is false outside a user-initiated // run, so this costs one Bool read on every other frame. if ecgProbeArmed { noteEcgProbeFrame(frame) } - if frame.count > 10, frame[8] == 0x24, frame[10] == WhoopCommand.getDataRange.rawValue { + // Gated on the FULL verdict for the same reason as the 4.0 path: this reply sets the + // window the offload judges its records against. The verdict is taken here rather + // than threaded from the seam because the 5/MG loop hands the router raw bytes; a + // data-range reply arrives once or twice per connection, so the parse is not a cost + // on the frame flood. + if frame.count > 10, frame[8] == 0x24, + DataRange.acceptsReply(frame, cmdOff: 10, opcode: WhoopCommand.getDataRange.rawValue, + verdictOK: parseFrame(frame, family: .whoop5).ok) { // feedsSync: false — #695 diagnostic-only on 5/MG: log the dump/backlog/newest/oldest so // a strap log validates the decode, but DON'T feed strapNewestTs/backfill/state yet. // Flip to true once a real 5.0/MG strap confirms the newest/oldest are correct. diff --git a/Strand/BLE/ClientHelloOutcome.swift b/Strand/BLE/ClientHelloOutcome.swift index 326a6a25c7..5018b71075 100644 --- a/Strand/BLE/ClientHelloOutcome.swift +++ b/Strand/BLE/ClientHelloOutcome.swift @@ -84,5 +84,41 @@ enum ClientHelloOutcome { return "CLIENT_HELLO outcome: completion from \(whereFrom)\(st) with NO hello outstanding — not a bond," + " so the link stays unbonded (#1635)" } + + /// The shortest an ATT round trip can plausibly take, in milliseconds. + /// + /// BLE's minimum connection interval is 7.5ms. A peripheral may answer inside the same connection + /// event, so this is not a hard floor and is NOT used to reject anything — it decides only whether + /// the log points the reader at the timing. It earns its place from the field distribution, which is + /// starkly bimodal: across 41 captures every hello either "completed" in 0, 4, 5 or 7ms, or produced + /// no callback at all about 3150ms later. Nothing in between, and 0ms cannot be a round trip at all. + /// + /// Byte-identical to the Android twin `MIN_PLAUSIBLE_ATT_ROUND_TRIP_MS`. + static let minPlausibleAttRoundTripMs = 8 + + /// #1883: a timing tell for Apple, where CoreBluetooth exposes no link-encryption state. + /// + /// On Android, `helloCompletionProvesEncryptedBond(osBonded) = osBonded` — only the OS bond state + /// attests encryption. CoreBluetooth exposes no bond state at all, so Apple has nothing to supply + /// for that check. The gap is real: a completion that is faster than one connection interval did + /// NOT come from the strap, and that is exactly the signature of #1635's false bond — a + /// `DISABLE_ALARM` completion misread as a hello ack because it arrived in 5ms. + /// + /// This line does NOT change any behavior. The bond still proceeds, the handshake still runs, and + /// `encryptedBond` is still set — because on Apple there is no alternative source of truth, and + /// refusing to bond would break every strap that genuinely bonds. The line only says what is + /// UNKNOWN: that the completion was suspiciously fast and the link's encryption state cannot be + /// verified on this platform. It is the diagnostic the issue's direction 2 asks for, and it is the + /// one that would have made #1635 visible on Apple instead of only on Android. + /// + /// Pure. No Kotlin twin — this is Apple-only by design (Android has `helloAckedWithoutEncryptionLine` + /// for the case where the OS bond state IS available and disagrees). + static func unverifiedBondTimingLine(elapsedMs: Int) -> String? { + guard elapsedMs < minPlausibleAttRoundTripMs else { return nil } + return "CLIENT_HELLO outcome: acked after \(elapsedMs)ms — under one BLE connection interval," + + " so the callback most likely came from the local stack rather than the strap. CoreBluetooth" + + " exposes no link-encryption state, so the bond is UNVERIFIED on this platform (#1883, #1635)." + + " The handshake proceeds because there is no alternative source of truth on Apple." + } } diff --git a/Strand/BLE/Commands.swift b/Strand/BLE/Commands.swift index f8ecac3628..eb0f554a2d 100644 --- a/Strand/BLE/Commands.swift +++ b/Strand/BLE/Commands.swift @@ -92,6 +92,11 @@ public enum WhoopCommand: UInt8, CaseIterable { /// payloads. If probing (#592): send THIS curated number first and capture the response — do NOT lead /// with the decompile's 87, an opcode unknown to this table (the curated-safe-subset rule). case getExtendedBatteryInfo = 98 + /// The 5/MG battery pack's fuel gauge, read THROUGH the strap — the pack talks to the strap over its + /// own link, so there is no peripheral to connect to and nothing to scan for. `BatteryPackInfo` has + /// decoded this reply since the offsets were captured; until now nothing sent the command, so the + /// decoder had no caller. A 4.0 never answers it (its pack is voltage-only via 98). + case getBatteryPackInfo = 151 /// #690: read-only body-location/status probe. Documented in the WHOOP protocol; driven only by the /// user-triggered, Test-Centre-gated probeBodyLocationAndStatus(). Decoded to a diagnostic report only. case getBodyLocationAndStatus = 84 @@ -226,6 +231,7 @@ public enum WhoopCommand: UInt8, CaseIterable { case .enterHighFreqSync: return "Enter High-Freq Sync" case .exitHighFreqSync: return "Exit High-Freq Sync" case .getExtendedBatteryInfo:return "Get Extended Battery Info" + case .getBatteryPackInfo: return "Get Battery Pack Info" case .getBodyLocationAndStatus:return "Get Body Location And Status" case .startFeatureFlagKeyExchange: return "Start Feature-Flag Key Exchange" case .sendNextFeatureFlag: return "Send Next Feature Flag" diff --git a/Strand/BLE/DisUnbondedRead.swift b/Strand/BLE/DisUnbondedRead.swift new file mode 100644 index 0000000000..c0ec3eb30d --- /dev/null +++ b/Strand/BLE/DisUnbondedRead.swift @@ -0,0 +1,83 @@ +import Foundation + +/// Should NOOP try reading the Device Information Service on a 5/MG that has NOT bonded? +/// +/// `readDisIdentity` is issued only inside the post-bond handshake, so a strap that never bonds never +/// reads DIS at all — and since #1635 the suppression makes that state PERMANENT rather than transient. +/// The Devices screen shows a WHOOP 4.0 with its firmware beside a 5/MG with none. The firmware, the +/// serial and the MG-vs-5.0 hardware revision all sit behind the same bond that never happens. +/// +/// Whether they NEED that bond is unproven, and the evidence is one-sided in an uncomfortable way. +/// Standard NOTIFICATIONS demonstrably work unbonded — live HR and battery both arrive that way on +/// exactly these straps — but nothing shows a standard READ working unbonded, and this file's own +/// platform carries the #490 note that a 5/MG refuses standard reads on an unencrypted link. A read is +/// also the same class of operation as the write that has been provoking the teardown, so this can +/// plausibly cost the stable link the suppression just bought. +/// +/// Hence: once per device, and never again after a refusal. [previouslyRefused] is persisted, so a strap +/// that says no says it once rather than on every reconnect forever. That makes the experiment +/// self-limiting in the same way the hello suppression is — "keeps trying something that will never +/// work" is the failure mode this whole area keeps producing. +/// +/// A refusal is a RESULT, not a defeat: it settles #490, which no capture has ever done. +/// +/// Kotlin twin: `com.noop.ble.shouldReadDisUnbonded`. +func shouldReadDisUnbonded( + isWhoop5: Bool, + bonded: Bool, + alreadyReadThisLink: Bool, + previouslyRefused: Bool +) -> Bool { + if !isWhoop5 { return false } + if bonded { return false } // the post-bond path already covers this + if alreadyReadThisLink { return false } + return !previouslyRefused +} + +/// Persisted key for "this strap refused an unbonded DIS read". Per device and lowercased, for the same +/// reason the hello-suppression key is. +/// +/// DIVERGENCE FROM ANDROID (deliberate, PII): the identifier here is the CoreBluetooth-local peripheral +/// UUID, per-install rather than the hardware address. Same key SHAPE, different kind of identifier. +/// +/// Kotlin twin: `com.noop.ble.disRefusedPrefKey`. +func disRefusedPrefKey(_ peripheralId: String?) -> String? { + guard let raw = peripheralId?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { + return nil + } + return "noop.disRefused.\(raw.lowercased())" +} + +/// The line for a DIS read that came back with a failure status. +/// +/// Without it the read fails into the generic notify-failure line, so a capture cannot tell a refusal +/// from a read that was never issued. That is the same ambiguity that made the CLIENT_HELLO failure +/// unreadable for eleven weeks, and it is not worth reproducing. +/// +/// Insufficient authentication / encryption are the interesting answers: they say the strap requires an +/// encrypted link for this read, which settles #490 and means the firmware simply cannot be had without +/// a bond. +/// +/// Pure. Byte-identical to the Kotlin `disReadFailureLine`. +func disReadFailureLine(uuid: String, status: String) -> String { + "DIS: read of \(uuid) failed \(status) — the strap declined it on this link. If this is an insufficient-authentication or -encryption status, DIS needs an encrypted bond and the firmware cannot be read without one (#490)" +} + +/// Should the standard Device Information Service firmware string be published for this strap? +/// +/// A 5/MG that never completes the puffin handshake has no firmware to show, because the only source +/// NOOP reads it from is a framed command that needs the bond. DIS `0x2A26` sits in the same service the +/// serial and hardware revision come from; it was simply never asked for on this platform. +/// +/// DIS is a FALLBACK, never an override. The puffin value is the strap's own report of the firmware it +/// is running and is what the 4.0 has always shown; DIS is whatever the device chose to publish in its +/// standard profile, and the two are not guaranteed to agree. So this yields to anything already decoded +/// rather than racing it — the decode lands later in the connect, and a value that appeared and then +/// changed would be worse than one that arrived once. +/// +/// Kotlin twin: `com.noop.ble.shouldPublishDisFirmware`. +func shouldPublishDisFirmware(disFirmware: String?, alreadyDecoded: String?) -> Bool { + guard let dis = disFirmware, !dis.trimmingCharacters(in: .whitespaces).isEmpty else { return false } + guard let decoded = alreadyDecoded else { return true } + return decoded.trimmingCharacters(in: .whitespaces).isEmpty +} diff --git a/Strand/BLE/FTMSSource.swift b/Strand/BLE/FTMSSource.swift index 0293558998..ef15fbbad6 100644 --- a/Strand/BLE/FTMSSource.swift +++ b/Strand/BLE/FTMSSource.swift @@ -200,7 +200,7 @@ extension FTMSSource: @preconcurrency CBCentralManagerDelegate { seenPeripherals[id] = peripheral let advName = advertisementData[CBAdvertisementDataLocalNameKey] as? String let name = advName ?? peripheral.name ?? "Gym Equipment" - if firstSight { log("FTMS: found \(name) (\(id)) rssi \(RSSI.intValue)") } + if firstSight { log("FTMS: found \(LiveState.logSafeDeviceName(name)) (\(id)) rssi \(RSSI.intValue)") } let machine = DiscoveredMachine(id: id, name: name, rssi: RSSI.intValue) if let idx = discovered.firstIndex(where: { $0.id == id }) { discovered[idx] = machine diff --git a/Strand/BLE/FrameRouter.swift b/Strand/BLE/FrameRouter.swift index 7186afea37..2183dafd9c 100644 --- a/Strand/BLE/FrameRouter.swift +++ b/Strand/BLE/FrameRouter.swift @@ -1,5 +1,6 @@ import Foundation import WhoopProtocol +import WhoopStore import StrandAnalytics /// Pure decode→state router. Takes a COMPLETE (already reassembled) frame, decodes it with @@ -9,7 +10,19 @@ public final class FrameRouter { private let state: LiveState /// Called when the strap pushes an EVENT packet (WHOOP's strap-as-clock catch-up signal). The /// BLEManager wires this to a rate-limited requestSync(.strap). nil in pure/unit contexts. + /// #1193: the WHOOP 4.0 strap serial, decoded from the `GET_HELLO_HARVARD` (35) response. A 4.0 has + /// no DIS serial, so this is its only stable identity — see `Whoop4HelloSerial`. Fires on every hello; + /// the manager decides whether it is confirmed enough to adopt. + var onStrapSerial: ((String) -> Void)? + var onSyncTrigger: (() -> Void)? + /// #1706: which strap this connection is talking to, so an alarm readback can be attributed to a + /// device. Set per connection by BLEManager immediately AFTER `family`, whose didSet clears this — + /// a path that sets the family and forgets the id then attributes nothing rather than carrying the + /// previous connection's strap forward, which is the very mistake this attribution exists to stop. + /// nil in pure/unit contexts, which the verdict treats as unattributed rather than guessing. + var deviceId: String? + /// Which family's framing to decode with. Set per connection by BLEManager. WHOOP 5.0/MG frames /// use the CRC16/offset-8 envelope; the biometric field decode for puffin is still a stub, so /// WHOOP 5 custom frames currently surface only their envelope (live HR/battery come from the @@ -18,9 +31,22 @@ public final class FrameRouter { // #900: a fresh connection is a fresh capture session — re-arm the per-command raw-frame dump so // each connect can re-capture the disputed COMMAND_RESPONSE prefix once. `family` is set fresh per // connection by BLEManager (connectCore), so this is the per-session reset hook. - didSet { rawDumpedRespCmds.removeAll(); loggedFirmwareGate = nil } + didSet { + rawDumpedRespCmds.removeAll(); loggedFirmwareGate = nil; deviceId = nil + rejectTally = FrameRejectTally(); loggedRejectReasons.removeAll() + } } + /// Rejected frames on this connection, per reason, plus the one named counter for the class that + /// used to pass the gates (payload CRC32 verified, envelope not). Reset per connection alongside the + /// other per-connection routing state, since that is the unit the readout is about. + public private(set) var rejectTally = FrameRejectTally() + + /// Reasons already reported on this connection, so the Test Centre line is one per REASON rather + /// than one per frame: a noisy link rejects continuously, and a per-frame line would bury the + /// transition that carries the information. + private var loggedRejectReasons: Set = [] + /// #900: resp command names (e.g. "GET_BATTERY_LEVEL(26)") whose raw COMMAND_RESPONSE frame has already /// been dumped this connection. The provenance dump fires once per command per session so a 4.0's /// per-poll battery reads don't flood the strap log. Reset when `family` is set at connect. @@ -51,9 +77,14 @@ public final class FrameRouter { assert(parsed == parseFrame(frame, family: family), "FrameRouter.handle: threaded ParsedFrame != fresh parse (#47 parse-once invariant)") #endif - guard parsed.ok else { return } - // Reject frames that failed their checksum — never let bad bytes drive state. - if parsed.crcOK == false { return } + // ONE gate, the verifier's FULL verdict: header checksum, payload CRC32 and structural length + // together. This used to be two steps — a parse-succeeded flag, then a separate payload-CRC + // check — and between them sat the class this change closes: a frame whose payload CRC32 is + // right while its header checksum or declared length is not. Never let bad bytes drive state. + guard parsed.ok else { + noteRejectedFrame(parsed) + return + } // #987: stamp frame liveness for the Connection readout's "last frame" row. A plain (non- // published, see LiveState) Int write, so the raw flood costs no re-renders here. @@ -149,6 +180,32 @@ public final class FrameRouter { let verdict = r == nil ? "no result byte" : (accepted ? "accepted" : "REJECTED") state.append(log: "reboot: strap acked result=\(rhex) (\(verdict))") } + // #1823: the clock exchange, on BOTH families. NOOP wrote "clock synced" the instant it queued + // the writes and never read the answer, so a strap log asserted the clock was set while the + // readout said 1970/71 — two contradictory lines with nothing to separate them. Same + // accept/reject shape REBOOT_STRAP already uses: the family's own result offset and polarity + // (5/MG 1=SUCCESS, 4.0 0=SUCCESS). LOG-ONLY; it never gates behaviour. + if let cmd = parsed.cmdName, cmd.hasPrefix("SET_CLOCK") || cmd.hasPrefix("GET_CLOCK") { + // NO accept/reject verdict here, on EITHER family, and that is deliberate. + // + // 4.0's 0=accepted is the reboot probe's own explicitly UNVERIFIED reading. And on 5/MG + // the result byte may not exist at all for this command: the captured-frame fixture builds + // a puffin COMMAND_RESPONSE as [36, seq, cmd] + payload at offset 8, so @11 is already + // PAYLOAD and the @12 that `commandResultByte` reads is a payload byte, not a result code. + // REBOOT_STRAP's use of it was validated against reboot's own frames; nothing establishes + // it for the clock. + // + // Inventing a verdict from that is precisely the fault this line was added to fix - the + // old "clock synced" log asserted an outcome nobody had checked. So quote the evidence + // and let a maintainer decode it: the byte at the family's result offset, and the WHOLE + // frame (#900's format), uncapped. A truncated clock frame answers nothing, and the full + // frame is what makes a wrong offset assumption visible instead of silently misleading. + let r = Self.commandResultByte(in: frame, family: family) + let rhex = r.map { String(format: "0x%02x", UInt8(truncatingIfNeeded: $0)) } ?? "none" + state.append(log: "clock: \(cmd) reply byte@resultOffset=\(rhex) " + + "frame=\(Self.fullFrameHex(frame))", + domain: .connection) + } if family == .whoop4, let cmd = parsed.cmdName { if cmd.hasPrefix("GET_ADVERTISING_NAME_HARVARD") { if let name = Self.advertisingName(in: frame), !name.isEmpty { @@ -175,15 +232,41 @@ public final class FrameRouter { // #34: persist what the strap reports so the debug export can show sent-vs-reported. let d = UserDefaults.standard d.set(Int(epoch), forKey: "alarm.lastReportedEpoch") + // #1706: the strap this readback came from, and the bytes it came in. The raw + // frame is what separates a genuinely-stored stale alarm from a misdecode of a + // fixed response field, and the live log rolls long before a debug export is + // taken — a 2045 readback went unexplained for exactly that reason. + d.set(deviceId, forKey: "alarm.lastReportedDeviceId") + d.set(raw, forKey: "alarm.lastReportedRaw") d.set(Date().timeIntervalSince1970, forKey: "alarm.lastReportedAt") // #34: count CONSECUTIVE rejections (reported ≠ what we last sent) — the signature of // a corrupted strap alarm register. A matching readback resets it, so a transient // (first read stale, then correct) never trips the warning; only a persistent refusal // climbs. SmartAlarmView surfaces the warning at ≥2; the debug export shows the count. // Observability only — never gates the BLE arm. + // #1706: the streak raises a UI warning at two, so it must only COUNT a + // disagreement PROVEN to be the same strap. A cross-strap reading is evidence of + // nothing and leaves the streak alone — advancing would warn about a strap that + // was never asked, clearing would hide a real refusal. An unattributed one is a + // different case, handled below. if let sent = d.object(forKey: "alarm.lastArmSentEpoch") as? Int { - d.set(abs(Int(epoch) - sent) > 120 ? d.integer(forKey: "alarm.rejectStreak") + 1 : 0, - forKey: "alarm.rejectStreak") + let verdict = AlarmReadback.verdict( + sentEpoch: sent, + reportedEpoch: Int(epoch), + sentDeviceId: d.string(forKey: "alarm.lastArmDeviceId"), + reportedDeviceId: deviceId) // the local, not a re-read of what we just wrote + if AlarmReadback.countsAsRejection(verdict) { + d.set(d.integer(forKey: "alarm.rejectStreak") + 1, forKey: "alarm.rejectStreak") + } else if AlarmReadback.clearsRejectionStreak(verdict) { + d.set(0, forKey: "alarm.rejectStreak") + } else if verdict == .unattributed { + // Any streak standing here was built by the cross-strap comparison this + // replaces, so it cannot be trusted — and since only a proven match clears + // the streak now, leaving it would hold SmartAlarmView's warning up forever + // on an install that upgraded mid-streak. Discard once; the next attributed + // readback rebuilds it honestly. + d.set(0, forKey: "alarm.rejectStreak") + } } } else if Self.readbackReportsNoAlarm(in: frame) { // #34 (issue comment 2026-07-12): the strap's "nothing armed" sentinel — the epoch @@ -209,7 +292,7 @@ public final class FrameRouter { let r = Self.commandResultByte(in: frame) let rhex = r.map { String(format: "0x%02x", UInt8(truncatingIfNeeded: $0)) } ?? "none" state.append(log: "Alarm: strap answered the arm (SET_ALARM_TIME) with result=\(rhex) — log-only, 4.0 result-code meaning unverified") - } else if cmd.hasPrefix("GET_HELLO_HARVARD"), TestCentre.active(.connection) { + } else if cmd.hasPrefix("GET_HELLO_HARVARD") { // #1303: capture aid for WHOOP-4.0 stable-serial identity. The strap serial lives in this // GET_HELLO_HARVARD (cmd 35) response. This used to dump the payload RAW, which answered // the question — the serial is the 9-char alnum run at offset 14 — but a captured 4.0 @@ -225,10 +308,18 @@ public final class FrameRouter { // the 5/MG device-name offset and means nothing in a cmd-35 payload — passing it would // mislabel whatever run happened to start there. Log-only; decodes/persists nothing. let helloPay = Self.commandResponsePayload(in: frame) ?? [] - state.append(log: HelloIdentityProbe.report(payload: helloPay, - block: "HELLO_HARVARD(35)", - knownNameOffset: -1) - + " — locate the strap serial offset (#1303)") + // #1193: the identity read is UNGATED, unlike the probe below it. Adoption has to + // work for every 4.0 user, and Test Centre is off for almost all of them — gating it + // would ship a stable id only to the people already debugging. The decoder reads a + // fixed 9-byte window and can never reach the device key beside it, so nothing here + // widens what an ordinary session touches. + if let serial = Whoop4HelloSerial.decode(payload: helloPay) { onStrapSerial?(serial) } + if TestCentre.active(.connection) { + state.append(log: HelloIdentityProbe.report(payload: helloPay, + block: "HELLO_HARVARD(35)", + knownNameOffset: -1) + + " — locate the strap serial offset (#1303)") + } } } // #1303: the 5/MG half of the same hunt. The 4.0 aid above is 4.0-only — correctly, since a @@ -250,6 +341,31 @@ public final class FrameRouter { let pay = Self.commandResponsePayload(in: frame, family: family) { state.append(log: HelloIdentityProbe.report(payload: pay) + " — locate the strap serial (#1303)") } + // The 5/MG battery pack (cmd 151). `BatteryPackInfo` has decoded this reply since its offsets + // were captured, and until now nothing sent the command — so the decoder had no caller and the + // offsets have never been seen against a live strap. + // + // LOG-ONLY, deliberately. Those offsets are an unvalidated candidate re-derived from two + // frames, and a wrong one does not fail: it renders a confident wrong number. So this reports + // what it read AND whether the reading passes the `displayable` sanity check, which is exactly + // the evidence needed before a card can honestly show it. Test Centre → Connection gated, so + // nothing here reaches a default (shareable) strap log. Persists nothing. + if family == .whoop5, let cmd = parsed.cmdName, cmd.hasPrefix("GET_BATTERY_PACK_INFO("), + TestCentre.active(.connection) { + if let info = BatteryPackInfo.decode(frame: frame) { + let soc = info.socPct.map { String(format: "%.1f%%", $0) } ?? "—" + // logSafe, NOT the raw serial. `redactPii` cannot catch this one — its rules key on a + // literal "WHOOP " prefix or a `whoop-` id, and a bare `serial=BB5AP…` matches neither — + // so the redaction that protects the strap's serial would have let the pack's through to + // an exportable log. Three characters is enough to tell two packs apart, which is all a + // diagnostic needs. + state.append(log: "[pack] present=\(info.present) soc=\(soc) " + + "serial=\(WhoopSerialIdentity.logSafe(serial: info.serial)) " + + "displayable=\(info.displayable) (#1303)") + } else { + state.append(log: "[pack] cmd 151 replied but did not decode — offsets may have moved") + } + } // #900: surface a non-SUCCESS COMMAND_RESPONSE on BOTH families (a result=UNSUPPORTED here is how // the MG haptics rejection #48 would show), and — the key part — annotate a reply that DELIVERED // ITS VALUE rather than reporting a bare failure. The 4.0 GET_BATTERY_LEVEL replies on record carry @@ -289,6 +405,20 @@ public final class FrameRouter { } } + case "CONSOLE_LOGS": + // The 5/MG strap narrates its own sync engine here — "BLE: PullStats: Data: N, Events: N…", + // "History burst success. Trim: 0x…", "Historical Dump Complete". Android has mirrored this + // into the strap log since #78 and calls it gold for protocol research; this side decoded the + // text and then dropped it on the floor, so an Apple strap log has never carried a word of it. + // + // It is worth more than curiosity. `PullStats: Data: 0` is the STRAP stating it sent no + // records, which is a far stronger answer to a "synced but no data" report (#1683) than NOOP + // inferring emptiness from its own decode — the difference between the strap saying nothing + // was there and us saying we found nothing. + // + // Capped at 300 characters to match the Kotlin twin exactly; the ring buffer holds 2k lines. + appendStrapConsole(parsed) + case "EVENT": if let ev = parsed.parsed["event"]?.stringValue { // #92: don't surface the live-HR stream toggle (BLE_REALTIME_HR_ON/OFF) in "Last @@ -334,6 +464,22 @@ public final class FrameRouter { } else if ev.hasPrefix("CHARGING_OFF") { state.charging = false } + // #1826: BATTERY_PACK_CONNECTED(21) / BATTERY_PACK_REMOVED(22), declared in the shared + // schema and handled on neither platform until @Zebsi235 measured them. On a 5/MG they + // fire on every attach and detach and LEAD the 7/8 edges above, so the pill responds when + // a pack goes on instead of waiting to catch a later edge. A WHOOP 4.0 never sends them. + // + // NO replay guard here, unlike the Kotlin twin. That is deliberate and not an omission: + // this router is live-only — the Backfiller holds no reference to it, so a replayed + // offload event never reaches this code, which is the same reason the CHARGING_ON/OFF + // branch above carries none. Android's EVENT routing does see replays, and its capture + // showed the strap re-sending these edges with byte-identical payloads, so the gate is + // load-bearing THERE. Copying it here would guard against something that cannot happen. + if ev.hasPrefix("BATTERY_PACK_CONNECTED") { + state.charging = true + } else if ev.hasPrefix("BATTERY_PACK_REMOVED") { + state.charging = false + } // Physical inputs the strap exposes — live only (this path never sees historical // replay, which goes through the Backfiller). Event strings are "NAME(rawValue)". if ev.hasPrefix("DOUBLE_TAP") { @@ -415,8 +561,14 @@ public final class FrameRouter { /// Space-separated lowercase hex of a COMMAND_RESPONSE payload, for the raw-hex diagnostic fallback /// when a readback payload doesn't decode. nil when the frame carries no payload. - nonisolated static func commandResponsePayloadHex(in frame: [UInt8]) -> String? { - guard let payload = commandResponsePayload(in: frame), !payload.isEmpty else { return nil } + /// #1823: takes `family` because `commandResponsePayload` slices at a family-specific inner offset + /// (5/MG 8, 4.0 its own). This wrapper used to drop the argument and always slice at the 4.0 offset, + /// so a 5/MG payload came back shifted - the same fixed-offset mistake the REBOOT_STRAP comment + /// records, and it would have mis-read the clock payload on the family the clock diagnostic is for. + /// Defaulted to `.whoop4` so the existing WHOOP4-gated alarm caller is unchanged. + nonisolated static func commandResponsePayloadHex(in frame: [UInt8], + family: DeviceFamily = .whoop4) -> String? { + guard let payload = commandResponsePayload(in: frame, family: family), !payload.isEmpty else { return nil } return payload.map { String(format: "%02x", $0) }.joined(separator: " ") } @@ -437,10 +589,12 @@ public final class FrameRouter { } /// Extract the armed-alarm epoch from a GET_ALARM_TIME (cmd 67) COMMAND_RESPONSE, defensively. - /// The WHOOP 4.0 response layout is UNDOCUMENTED, so this tries the two shapes the firmware could - /// plausibly answer with - the SET_ALARM_TIME mirror (`[form 0x01][u32 LE epoch]…`, matching the - /// 9-byte payload we arm with) first, then a bare leading u32 LE - and accepts a candidate only when - /// it passes `isPlausibleAlarmEpoch`. Anything else returns nil and the caller logs raw hex instead. + /// The WHOOP 4.0 response layout is UNDOCUMENTED, so this tries the shapes the firmware has been + /// seen to answer with - the 11-byte GET readback captured on fw 41.17.6.0 + /// (`[form 0x01][stored flag][u32 LE epoch][00 00][04 00 20]`, epoch at offset 2) first, then the + /// SET_ALARM_TIME mirror (`[form 0x01][u32 LE epoch]…`, matching the 9-byte payload we arm with), + /// then a bare leading u32 LE - and accepts a candidate only when it passes + /// `isPlausibleAlarmEpoch`. Anything else returns nil and the caller logs raw hex instead. /// Pure and CoreBluetooth-free so golden tests pin it (AlarmReadbackDecodeTests). nonisolated static func armedAlarmEpoch(in frame: [UInt8]) -> UInt32? { guard let payload = commandResponsePayload(in: frame) else { return nil } @@ -451,14 +605,29 @@ public final class FrameRouter { | (UInt32(payload[i + 2]) << 16) | (UInt32(payload[i + 3]) << 24) } + // The GET readback (fw 41.17.6.0, three arm/readback captures 2026-08-26..28, #34/#1706): the + // epoch sits ONE byte further than in the SET mirror, because the readback carries a stored + // flag (0x00 = nothing stored, 0x01 = stored) the arm payload does not. The mirror-offset read + // of this shape returns the epoch's LOW THREE bytes shifted up a byte, plus the flag — wrong + // by roughly 256x and free to land anywhere in u32 range. In all three captures it landed on + // a 2045 date INSIDE the 2017..2100 plausibility window (an arm for 2026-08-26 read back as + // 2045-09-24), so the gate did not catch it and a MISMATCH was counted against a strap whose + // register is fine. So on this shape the mirror offsets are known-wrong and must NOT be tried: + // offset 2 decodes, or the payload falls to the raw-hex line. + if payload.count == 11, payload.first == 0x01 { + if let e = u32le(at: 2), isPlausibleAlarmEpoch(e) { return e } + return nil + } if payload.first == 0x01, let e = u32le(at: 1), isPlausibleAlarmEpoch(e) { return e } if let e = u32le(at: 0), isPlausibleAlarmEpoch(e) { return e } return nil } /// True when a GET_ALARM_TIME readback explicitly reports NO alarm stored — the epoch field decodes - /// to 0 in the same shapes `armedAlarmEpoch` reads (SET-mirror `[0x01][u32=0]` first, then a bare - /// leading `u32=0`). This is the strap's "nothing armed" sentinel, distinct from a genuinely + /// to 0 in the same shapes `armedAlarmEpoch` reads (the 11-byte GET readback `[0x01][flag][u32=0]…` + /// first — the #34 field-report payload `01 00 00 00 00 00 00 00 04 00 20` is exactly this shape + /// with the stored flag 0x00 — then the SET-mirror `[0x01][u32=0]`, then a bare leading `u32=0`). + /// This is the strap's "nothing armed" sentinel, distinct from a genuinely /// unparseable payload: an arm the strap silently dropped reads back as epoch 0, so labelling it /// "unrecognised" hid the real signal (#34). Only consulted AFTER `armedAlarmEpoch` returns nil, so a /// plausible armed epoch never reaches here. Pure/CoreBluetooth-free so AlarmReadbackDecodeTests pin it. @@ -471,6 +640,7 @@ public final class FrameRouter { | (UInt32(payload[i + 2]) << 16) | (UInt32(payload[i + 3]) << 24) } + if payload.count == 11, payload.first == 0x01, let e = u32le(at: 2) { return e == 0 } if payload.first == 0x01, let e = u32le(at: 1) { return e == 0 } if let e = u32le(at: 0) { return e == 0 } return false @@ -509,6 +679,94 @@ public final class FrameRouter { /// RTC (fix #72) — a live gesture is ~now in the strap's clock, a historical replay is old in it. /// Deliberately does NOT touch lastEvent / sync trigger / bonded / battery — those stay on the normal /// handle(frame:) path, so backfill UI behaviour is otherwise unchanged. + /// Mirror a CONSOLE_LOGS frame's text even during a backfill. + /// + /// The strap narrates its sync engine EXACTLY while offloading — "BLE: PullStats: Data: N", + /// "History burst success. Trim: 0x…", "Historical Dump Complete" — and offload frames are routed + /// straight to the Backfiller, bypassing `handle` entirely. So the `case "CONSOLE_LOGS"` there only + /// ever sees the rare console frame that arrives outside a sync, which is not the one worth having. + /// This is the same carve-out `dispatchLiveGestureIfFresh` makes for a live gesture mid-offload. + /// + /// Same cheap pre-check as that method: a single type-byte compare skips the CRC + FieldBuilder + /// decode for the thousands of type-47 records a sync produces, so the cost on the offload path is a + /// byte compare per frame. Family-aware (WHOOP4 type @[4], 5/MG @[8]). + func mirrorStrapConsoleIfPresent(frame: [UInt8]) { + guard frameTypeName(frame, family: family) == "CONSOLE_LOGS" else { return } + let parsed = parseFrame(frame, family: family) + // The full verdict, in one step — the frame is parsed right here, so `ok` already covers the + // payload CRC32 the second condition used to check separately. + guard parsed.ok else { return } + appendStrapConsole(parsed) + } + + /// Count one rejected frame and say something about it exactly once (D3). + /// + /// Two different visibility rules, on purpose: + /// + /// - The class where the payload CRC32 VERIFIED while the envelope did not is announced always-on, + /// at its first sighting. It is the class that passed every gate before this change, it is what + /// the hardware run's abort criterion reads, and it costs nothing on a link where it never + /// happens — which is the whole point of leaving rare-event evidence unconditional. + /// - The ordinary per-connection detail sits behind the Test Centre's Connection domain, one line + /// per REASON. A resync after a lost notification rejects frames routinely and always has; a line + /// per frame would be noise, and the general per-reason counter is explicitly NOT the abort signal. + /// + /// Each line reports only what the parse result observed: the reason the verifier gave, and the + /// packet type the decoder actually read (a rejected frame keeps it). + private func noteRejectedFrame(_ parsed: ParsedFrame) { + let hadAdmittedClass = rejectTally.payloadCRCOKButEnvelopeRejected > 0 + let reason = rejectTally.note(parsed) + if !hadAdmittedClass && rejectTally.payloadCRCOKButEnvelopeRejected > 0 { + state.append(log: "Frame rejected while its payload CRC32 verified " + + "(reason=\(reason.rawValue), type=\(parsed.typeName), \(parsed.lenBytes) bytes) " + + "— the frame class that reached live state before the integrity gate.") + } + if TestCentre.active(.connection), loggedRejectReasons.insert(reason).inserted { + state.append(log: "frameReject reason=\(reason.rawValue) type=\(parsed.typeName) " + + "bytes=\(parsed.lenBytes)", domain: .connection) + } + } + + /// Count an OFFLOAD frame's verdict on this connection (D3). + /// + /// The BLE seam routes a replayed history frame straight to the Backfiller, so `handle(parsed:frame:)` + /// — the only caller of `noteRejectedFrame` — never sees it. Without this the tally, and with it the + /// ONE counter the hardware run's abort criterion is read from, stays at zero during exactly the + /// traffic in which a wrongly-emptied and then acked section is a permanent loss. The verdict is + /// formed once at the seam and handed over here; nothing is parsed twice. + /// + /// The line reports only what the verifier observed. There is no packet type in it because nothing + /// decoded one on this path — naming the offload is what this evidence can attribute. + func noteOffloadFrameVerdict(_ check: FrameCheck) { + let hadAdmittedClass = rejectTally.payloadCRCOKButEnvelopeRejected > 0 + let reason = rejectTally.note(check) + guard reason != .none else { return } + if !hadAdmittedClass && rejectTally.payloadCRCOKButEnvelopeRejected > 0 { + state.append(log: "Frame rejected while its payload CRC32 verified " + + "(reason=\(reason.rawValue), during a history offload) " + + "— the frame class that reached live state before the integrity gate.") + } + if TestCentre.active(.connection), loggedRejectReasons.insert(reason).inserted { + state.append(log: "frameReject reason=\(reason.rawValue) offload=true", domain: .connection) + } + } + + /// Fold the reassembler's monotonic below-minimum drop count into this connection's tally, so a byte + /// run dropped before any parser saw it still shows up as a rejection with the reason + /// "belowMinimumLength" instead of vanishing. Idempotent — only the growth since the last call is + /// added, so the BLE seam can call it after every notification. + func noteReassemblerDrops(_ monotonicTotal: Int) { + rejectTally.absorbReassemblerDrops(monotonicTotal) + } + + /// The one place the strap's own narration reaches the log, so the live and offload paths cannot + /// drift in what they emit. Capped at 300 characters to match the Kotlin twin exactly. + private func appendStrapConsole(_ parsed: ParsedFrame) { + guard parsed.typeName == "CONSOLE_LOGS", + let txt = parsed.parsed["log"]?.stringValue, !txt.isEmpty else { return } + state.append(log: "strap: \(String(txt.prefix(300)))") + } + func dispatchLiveGestureIfFresh(frame: [UInt8], now: Int = Int(Date().timeIntervalSince1970)) { // #47: this fires for EVERY frame on the OFFLOAD path (thousands of type-47 records over a // multi-minute sync) purely to catch a rare EVENT gesture. Cheap type-only pre-check skips the full @@ -517,7 +775,9 @@ public final class FrameRouter { // anyway. Family-aware (WHOOP4 type @[4], 5/MG @[8]). guard frameTypeName(frame, family: family) == "EVENT" else { return } let parsed = parseFrame(frame, family: family) - guard parsed.ok, parsed.crcOK != false else { return } + // Same single gate as `handle`: a gesture changes worn state, so it may only come from an + // intact frame. `frameTypeName` is a type PEEK and says nothing about integrity. + guard parsed.ok else { return } guard parsed.typeName == "EVENT", let ev = parsed.parsed["event"]?.stringValue else { return } guard let ts = parsed.parsed["event_timestamp"]?.intValue, ts > 0 else { return } // fail closed guard abs(now - ts) <= FrameRouter.liveGestureWindowSeconds else { return } diff --git a/Strand/BLE/HelloSuppression.swift b/Strand/BLE/HelloSuppression.swift new file mode 100644 index 0000000000..0e1dd4b1d2 --- /dev/null +++ b/Strand/BLE/HelloSuppression.swift @@ -0,0 +1,156 @@ +import Foundation +import WhoopProtocol + +/// Whether to send the WHOOP 5/MG CLIENT_HELLO at all on this connect. +/// +/// The #1635 field captures show the hello is what ends the link. Across two sessions and sixteen writes it +/// was never once acknowledged, and the drop is locked to the HELLO rather than to the connect: 3.158s, +/// 3.159s, 3.155s after the write, cycle after cycle, while live HR streams happily over the standard +/// profile the whole time. +/// +/// An HCI capture later established the cause: the phone transmits an SMP Pairing Request and the strap +/// answers `Pairing Not Supported` (0x05). The refusal is categorical — the encrypted bond this hello waits +/// behind can never arrive on a 5/MG — so the write is a handshake that has never once succeeded knocking a +/// strap that CAN deliver live HR off every few seconds. +/// +/// Suppressing it after the give-up latches trades an unreachable capability (puffin commands, history +/// offload) for one that demonstrably works (continuous live HR) — the "Live HR, not fully paired" state +/// the app already models (#69), reached deliberately instead of never at all. +/// +/// [userInitiated] always re-attempts: suppression is a fallback for automatic reconnects, never a +/// permanent verdict. Someone who puts the strap in pairing mode and presses Connect must get a fresh try. +/// That try is ONE connect's worth and nothing more — the latch itself survives the tap, so if the strap +/// still will not answer, the automatic reconnects behind it stay suppressed instead of re-earning the +/// give-up over five more refusals. The latch is dropped by a genuine bond (it demonstrably works now) or +/// by forgetting the strap, which are the two events that are actually evidence about this device. +/// +/// Kotlin twin: `com.noop.ble.shouldSendClientHello`. +func shouldSendClientHello(suppressedForDevice: Bool, userInitiated: Bool) -> Bool { + !suppressedForDevice || userInitiated +} + +/// How many consecutive refusals this cause needs before the give-up latches. +/// +/// The flat 5 was argued for the AUTH-REFUSAL branch and only makes sense there: the pairing hint shows at +/// 2, the hint asks the user to do something (close the official app, free a stale phone pairing), and the +/// extra cycles are the time to do it before NOOP stops hammering. +/// +/// An unanswered handshake gives the user nothing to act on. The write vanishes, the strap is not refusing +/// anything it could be talked out of, and the outcome — suppress the hello and keep streaming live HR — +/// needs no permission and costs no capability that was reachable anyway. Every cycle spent waiting is a +/// ~4.8s link drop bought for nothing, so this branch stops at 3. +/// +/// Not 2, which is exactly where the pairing hint fires: a strap whose hello is unanswered twice by some +/// transient would latch a PERSISTED verdict with no margin at all. 3 keeps one cycle of margin and still +/// takes roughly 40% off the churn. +/// +/// Kotlin twin: `com.noop.ble.UNANSWERED_GIVE_UP_THRESHOLD`. +let unansweredGiveUpThreshold = 3 + +/// The give-up threshold for the refusal in hand. Keyed on the same `authRefusal` that decides whether the +/// give-up suppresses or pauses (`giveUpSuppressesHello`), so the two can never disagree about which branch +/// a refusal belongs to. +/// +/// Kotlin twin: `com.noop.ble.giveUpThresholdFor`. +func giveUpThresholdFor(authRefusal: Bool, pauseThreshold: Int) -> Int { + authRefusal ? pauseThreshold : unansweredGiveUpThreshold +} + +/// Should the give-up latch suppress the hello, rather than pause auto-reconnect? +/// +/// The two give-up causes want opposite treatment, which is why they are split here rather than sharing +/// one branch: +/// +/// - An AUTH REFUSAL means the strap actively declined the encrypted bond — typically because it is still +/// held by the official WHOOP app. Reconnecting cannot help until the user acts, so pausing is right. +/// - An UNANSWERED HANDSHAKE means the write vanished. The link itself is healthy and streaming, so +/// pausing throws away working live HR to punish a handshake nobody is waiting on. Suppress the hello +/// and stay connected instead. +/// +/// Splitting them is the whole point: before this, both ended in the same pause, so the strap that could +/// still deliver HR was treated exactly like the one that could not. +/// +/// Kotlin twin: `com.noop.ble.giveUpSuppressesHello`. +func giveUpSuppressesHello(authRefusal: Bool) -> Bool { !authRefusal } + +/// Does this disconnect count as the strap refusing to bond? +/// +/// Two observations mean the same thing for the purpose of giving up: the strap answered the bond write +/// with INSUFFICIENT_AUTHENTICATION / INSUFFICIENT_ENCRYPTION, or it never answered the CLIENT_HELLO at +/// all. The second was invisible to the give-up on this platform, because the gate tested only the write +/// error — and an unanswered handshake produces no write error at all, just a link that drops a few +/// seconds later. The consequence is the loop #1635 describes, with nothing able to end it. +/// +/// Deliberately does NOT widen what counts as an AUTH refusal — [helloUnacked] is a separate signal carried +/// separately, so the user-facing guidance can stay honest about which was observed. An auth refusal +/// supports naming a cause; an unanswered handshake does not. +/// +/// Pure so the rule is unit-tested without a radio. Kotlin twin: `com.noop.ble.countsAsBondRefusal`. +func countsAsBondRefusal( + isAuthRefusalStatus: Bool, + helloUnacked: Bool, + alreadyBonded: Bool, + family: DeviceFamily +) -> Bool { + if alreadyBonded { return false } // bonded already — not a pairing problem + if family != .whoop5 { return false } // the 4.0 bonds cleanly; this is the 5/MG handshake + return isAuthRefusalStatus || helloUnacked +} + +/// UserDefaults key holding the hello-suppression latch for one strap. +/// +/// Per device, and lowercased for the same reason the firmware key is: the same strap can present its +/// identifier in different cases across sessions, and a case-sensitive key would silently latch a second +/// time under a second key instead of reading the first. +/// +/// DIVERGENCE FROM ANDROID (deliberate, PII): the identifier here is the CoreBluetooth-local peripheral +/// UUID, which is per-install rather than the hardware address. The key SHAPE is shared with the Kotlin +/// twin so the two are recognisably the same latch; the value that goes into it is not the same kind of +/// identifier, and deliberately so. +/// +/// Kotlin twin: `com.noop.ble.helloSuppressionPrefKey`. +func helloSuppressionPrefKey(_ peripheralId: String?) -> String? { + guard let raw = peripheralId?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { + return nil + } + return "noop.helloUnanswered.\(raw.lowercased())" +} + +/// Read/write the per-strap hello-suppression latch. Kept beside the key helper so no caller hand-rolls +/// the key, which is how the Android side ended up with a case-sensitivity bug worth a comment. +enum HelloSuppressionStore { + static func suppressed(_ peripheralId: String?) -> Bool { + guard let key = helloSuppressionPrefKey(peripheralId) else { return false } + return UserDefaults.standard.bool(forKey: key) + } + + static func setSuppressed(_ peripheralId: String?, _ suppressed: Bool) { + guard let key = helloSuppressionPrefKey(peripheralId) else { return } + if suppressed { + UserDefaults.standard.set(true, forKey: key) + } else { + // Remove rather than store false: an absent key and a false one mean the same thing, and this + // keeps a strap that never latched from leaving a permanent entry behind. + UserDefaults.standard.removeObject(forKey: key) + } + } +} + +/// May the keep-alive timer do its work on this link? +/// +/// #1635: the timer used to gate on the genuine encrypted bond alone. A SUPPRESSED 5/MG never reaches +/// one, so that gate switched the whole timer off for exactly the link the suppression keeps up for +/// hours — taking the liveness watchdog with it and leaving a stalled stream with nothing to bounce it. +/// The stable-live-HR promise would have quietly depended on a stream nothing was watching. +/// +/// So a 5/MG that is STREAMING (`bonded` is the live-HR shortcut of #8/#69, set when HR actually arrives +/// over the standard profile) is admitted too. Deliberately narrower than the Android twin, which admits +/// any `bonded` link: here it is 5/MG only, and only as far as the watchdog — the caller re-guards on the +/// real bond before anything puffin-framed, which an unbonded link cannot land anyway. +/// +/// Pure so the rule is pinned without a radio. +func keepAliveMayRun(connected: Bool, didBond: Bool, bonded: Bool, family: DeviceFamily) -> Bool { + guard connected else { return false } + if didBond { return true } + return bonded && family == .whoop5 +} diff --git a/Strand/BLE/HuamiHRSource.swift b/Strand/BLE/HuamiHRSource.swift index 4e54de5411..eae770dd40 100644 --- a/Strand/BLE/HuamiHRSource.swift +++ b/Strand/BLE/HuamiHRSource.swift @@ -230,7 +230,7 @@ extension HuamiHRSource: @preconcurrency CBCentralManagerDelegate { let id = peripheral.identifier let firstSight = seenPeripherals[id] == nil seenPeripherals[id] = peripheral - if firstSight { log("Huami: found \(name) (\(id)) rssi \(RSSI.intValue)") } + if firstSight { log("Huami: found \(LiveState.logSafeDeviceName(name)) (\(id)) rssi \(RSSI.intValue)") } let dev = DiscoveredDevice(id: id, name: name.isEmpty ? brand.displayBrand : name, rssi: RSSI.intValue) if let idx = discovered.firstIndex(where: { $0.id == id }) { discovered[idx] = dev diff --git a/Strand/BLE/LastSyncAttribution.swift b/Strand/BLE/LastSyncAttribution.swift new file mode 100644 index 0000000000..9af8316d2a --- /dev/null +++ b/Strand/BLE/LastSyncAttribution.swift @@ -0,0 +1,70 @@ +import Foundation + +/// Which "last sync" timestamp belongs to a strap, given what is known about it. +/// +/// The same defect `FirmwareAttribution` fixed for firmware strings, in the same file's other line. +/// `lastSyncedAt` is ONE defaults key, stamped on any strap's HISTORY_COMPLETE and read back for +/// whichever strap is active. On a single-strap install those are the same strap, which is why it went +/// unnoticed. With two paired it reports the other one's: an Android capture showed "Last sync: 4d ago" +/// beside zero banked rows for the active 5/MG, the timestamp belonging to a 4.0 last worn three days +/// earlier. +/// +/// Worse than merely wrong: it is wrong in the reassuring direction. A strap that has never synced reads +/// as recently synced, and the pull-to-refresh that cannot possibly change it looks broken rather than +/// inapplicable. On Android that sent an investigation after a sync regression that never existed. +/// +/// The rule, in order: +/// - `perDevice` wins: this strap's own stamp, from its own completed offload. +/// - `legacyGlobal` ONLY when `pairedCount` is 1. The old key cannot say which strap it belongs to, so +/// it is trustworthy exactly when there is only one strap it could have come from — which keeps a +/// single-strap install reading correctly across the upgrade instead of resetting to "never". +/// - otherwise nil, meaning "this strap has never synced". For a strap in that state it is not a +/// degraded answer, it is the correct one. +/// +/// Deliberately NOT migrated by writing the global onto the active device at upgrade: that is the same +/// guess in a different place, and on a multi-strap install it would bake the wrong attribution in +/// permanently instead of leaving it to self-correct at the next real sync. +/// +/// Pure so the attribution is unit-tested without defaults, a strap, or a registry. Kotlin twin: +/// `com.noop.ble.resolveLastSync`. +enum LastSyncAttribution { + + static func resolve(perDevice: Double?, + legacyGlobal: Double?, + pairedCount: Int) -> Double? { + if let perDevice, perDevice > 0 { return perDevice } + if let legacyGlobal, legacyGlobal > 0, pairedCount == 1 { return legacyGlobal } + return nil + } + + /// The per-device defaults key for a persisted last-sync timestamp. + /// + /// Keyed on the BLE peripheral identifier for the same reason `FirmwareAttribution.prefKey` is: + /// HISTORY_COMPLETE arrives on a connection whose peripheral is known, and resolving it to a registry + /// id there would repeat the mis-mapping #1527 fixed for `lastSeen` — stamping "the active row" + /// records a sync for a strap that was never connected, which is the class of error being removed. + /// + /// Lowercased, because the same strap can present its identifier in different cases across sessions + /// and a case-sensitive key would strand the earlier stamp under a second name. Returns nil for a + /// blank identifier, so a caller with none writes nothing rather than writing to a key that belongs + /// to no device. + static func prefKey(peripheralId: String?) -> String? { + guard let p = peripheralId?.trimmingCharacters(in: .whitespaces), !p.isEmpty else { return nil } + return "noop.lastSyncAt.\(p.lowercased())" + } + + /// The per-device defaults key for the #57 write-health stamps ("rows last landed", "offload stalled"). + /// + /// Same defect as `prefKey` and found in the same capture, one line below it: "Data write: rows last + /// landed 4d ago" printed against a strap whose own row count was zero, because the stamp was global + /// and the other strap had written it. + /// + /// `kind` separates "ok" from "stalled" so both keep the per-device scoping rather than only the one + /// that happened to be noticed — they are read as a PAIR ("stalled more recently than ok" is the + /// alarm), and scoping one without the other would compare a strap's own stall against another + /// strap's success. Kotlin twin: `com.noop.ble.writeHealthPrefKey`. + static func writeHealthPrefKey(peripheralId: String?, kind: String) -> String? { + guard let p = peripheralId?.trimmingCharacters(in: .whitespaces), !p.isEmpty else { return nil } + return "sync.\(kind).\(p.lowercased())" + } +} diff --git a/Strand/BLE/LiveState.swift b/Strand/BLE/LiveState.swift index bb324fc052..9eb0727585 100644 --- a/Strand/BLE/LiveState.swift +++ b/Strand/BLE/LiveState.swift @@ -24,6 +24,14 @@ public final class LiveState: ObservableObject { /// connect/disconnect. Drives the Live pill's two-state distinction; the encrypted channel (buzz, /// alarm, double-tap, history offload) only works when this is true. @Published public var encryptedBond: Bool = false + /// True once this strap can actually hand over history — the UI mirror of `BLEManager`'s + /// `connectHandshakeDone`, which `beginBackfill` already requires before it will request an offload. + /// + /// Exposed because `bonded` is NOT that condition and reads true too early: the live-HR path sets it + /// for a 5/MG that has never completed a handshake, so the sync controls were offered, accepted, and + /// then refused deeper down in silence. Gating on this makes them unavailable exactly when the sync + /// would have been declined anyway — never when it would have run. Kotlin twin: `LiveState.historyReady`. + @Published public var historyReady: Bool = false /// #34: bumped by BLEManager once a WHOOP 4.0 connection has BOTH run its connect handshake (hello + /// SET_CLOCK, exactly once — `connectHandshakeDone`) AND had the cmd-notify characteristic confirm /// subscribed (`didUpdateNotificationStateFor` for it fired with `isNotifying == true`) — whichever of @@ -68,10 +76,38 @@ public final class LiveState: ObservableObject { /// GET_EXTENDED_BATTERY_INFO response (#592). Shown on the Devices card as a "x.xx V" readout beside the /// percent; nil until the first battery event lands. Twin of the Android LiveState.batteryMv. @Published public var batteryMv: Int? = nil - /// Charging flag from the strap's BATTERY_LEVEL events — wire observation: u8 bit0 in the - /// event payload (4.0 @26 / 5.0 @30), pushed ~every 8 min on captured links. nil until the - /// first event of a session; cleared on disconnect so a stale flag can't outlive the link. - /// Flag ONLY — the battery % keeps its family-specific source (#77). + /// Charging flag. Two sources, and they mean different things (#1935). + /// + /// The authority is the strap's BATTERY_LEVEL event — wire observation: u8 bit0 in the payload + /// (4.0 @26 / 5.0 @30), pushed ~every 8 min on captured links. That is a LEVEL signal from the + /// strap's own gauge: every live battery event rewrites this flag, whatever it was. + /// + /// On a 5/MG it is ALSO set by `BATTERY_PACK_CONNECTED(21)` and cleared by + /// `BATTERY_PACK_REMOVED(22)`, which is a latency win — 21 leads `CHARGING_ON(7)` by up to ~17 s in + /// captures, so the pill responds when the pack goes on. But 21 means A PACK WAS ATTACHED, not that + /// charging began. They diverge on a depleted pack or a poor contact: 21 fires, 7 never does, and + /// this reads true while nothing charges. + /// + /// THAT STATE IS BOUNDED, which is why it is documented rather than split. It does not last until 22: + /// the next live BATTERY_LEVEL overwrites it from the strap's own GAUGE, so the window is about one + /// battery cadence, and the gauge always gets the last word. + /// + /// It gets the last word only because nothing else repeats, which is a real constraint rather than an + /// observation (#1935). The pack record reaches this platform LOG-ONLY (`FrameRouter`, Test Centre + /// gated) and writes nothing here. Android learned the same rule the hard way: its pushed pack-info + /// event (109) once wrote `charging = true` on pack PRESENCE every couple of minutes, outran the + /// gauge, and held a flat pack at "charging" for its whole attachment. An EDGE may set this flag + /// (7, 21, 22); a repeating presence signal must not, or the gauge cannot correct it. + /// + /// It matters beyond the pill. `BLEManager.lowPowerThrottleActive` reads it and gates THREE levers, not + /// one: the low-battery offload cadence, the connection-priority throttle, and the continuous-capture + /// pause behind the user's own "Pause HRV capture" percentage. So a pack attached but not charging can + /// keep background capture running at low battery after the user asked for it to stop, for that + /// window. `BLEManager.batteryPollDue` also reads it, polling every tick instead of every + /// other, which is harmless and arguably wanted with a pack on. + /// + /// nil until the first event of a session; cleared on disconnect so a stale flag can't outlive the + /// link. Flag ONLY — the battery % keeps its family-specific source (#77). @Published public var charging: Bool? = nil /// The Oura ring's current wear/charge state (nil for non-Oura straps or before any evidence this @@ -410,6 +446,13 @@ public final class LiveState: ObservableObject { /// True while a historical offload session is running, so screens can say "Syncing strap /// history…" instead of presenting half-loaded data as final (#77). @Published public var backfilling = false + /// #1164 — true when the strap reports banked records newer than our local HR frontier (the strap has + /// data we haven't ingested yet), even when no offload is actively running. Set by BLEManager from the + /// GET_DATA_RANGE newest vs. the collector's latest HR sample, with the same 5-min `behindGapSeconds` + /// the auto-continue predicate uses. Cleared on disconnect so a stale "pending" can't outlive the link. + /// Drives the Today Rest "Pending sync" state so a provisional score isn't shown as final before the + /// full night is offloaded. Twin of the Android LiveState.historyPendingSync. + @Published public var historyPendingSync = false /// Chunks acked during the current offload session — an honest progress signal (total pending is /// unknowable from the protocol, so a count, never a percent). @Published public var syncChunksThisSession: Int = 0 @@ -765,17 +808,176 @@ public final class LiveState: ObservableObject { /// service base (…-8d6d-82b8-614a-1c8cb0f8dcc6) — those are public, identical on every strap, and /// are exactly the GATT diagnostics a shared log needs to be useful (#421). Thanks @ujix (#447) for /// catching the peripheral-UUID leak; this is a targeted form so we don't redact the service UUIDs. + /// #1833: mask a WHOOP serial that arrives as HEX rather than as text. + /// + /// Every rule in `redactPii` matches an identifier written as characters. None can see one encoded + /// as hex, because they are reading hex digits and not the ASCII those bytes decode to. On a 5/MG, + /// event 109 carries the strap serial as plain ASCII inside its payload, so any diagnostic that dumps + /// a frame or payload puts the serial into the log we ask people to attach to public issues — while + /// the MAC rule keeps firing, so the line still LOOKS redacted. + /// + /// Deliberately NOT keyed on a label. This side writes hex as `frame=…` (the clock diagnostic), + /// `[raw …]` and `(raw …)` (the alarm readback), and the #900 whole-frame dump carries none. A rule + /// enumerating today's phrasings is one the next diagnostic slips past. Matching the hex itself needs + /// no maintenance and covers dumps not yet written. + /// + /// Only bytes inside a serial-shaped ASCII run are masked; the rest of the dump survives, because the + /// payload is exactly where an undocumented field would be found. Twin of `redactHexDumpPii`. + nonisolated static func redactHexDump(_ hex: String) -> String { + let chars = Array(hex) + // NO even-length requirement, deliberately. The run regex matches consecutive hex characters, so + // a dump abutting other hex-valid text yields an ODD-length match — and bailing on that returned + // the serial UNREDACTED, while the Kotlin twin processed the even prefix and masked it. Swift was + // the weaker half of a pair that has to behave identically. Process what pairs up, ignore a + // trailing half-byte. + guard chars.count >= 16 else { return hex } + var bytes = [UInt8](); bytes.reserveCapacity(chars.count / 2) + var i = 0 + while i + 1 < chars.count { + guard let b = UInt8(String(chars[i...(i + 1)]), radix: 16) else { return hex } + bytes.append(b); i += 2 + } + var out = chars + var runStart = -1 + func closeRun(_ end: Int) { + defer { runStart = -1 } + guard runStart >= 0 else { return } + // Anchor on the first LETTER with enough run left after it, mirroring the Kotlin regex + // `[A-Za-z][0-9A-Za-z]{8,}` — which finds a serial ANYWHERE inside an alphanumeric run, not + // only at its start. Testing just the run's first byte left the serial exposed whenever a + // digit happened to precede it with no separator, and Kotlin masked the same bytes. Two + // halves of one rule disagreeing about which payloads are safe is the failure to avoid. + var i = runStart + while i < end { + let b = bytes[i] + let isLetter = (b >= 65 && b <= 90) || (b >= 97 && b <= 122) + if isLetter, end - i >= 9 { + for k in i..= 48 && b <= 57) || (b >= 65 && b <= 90) || (b >= 97 && b <= 122) + if alnum { if runStart < 0 { runStart = idx } } else { closeRun(idx) } + } + closeRun(bytes.count) + return String(out) + } + + /// Tokens that identify a MODEL rather than a person, for `logSafeDeviceName`. + /// + /// Two shapes only, both EXACT: a known vendor, product or model word, and a version number ("4.0"). + /// + /// There is deliberately no letters-plus-digits pattern for model codes. One was tried and it + /// defeated the whole design: "[a-z]{1,4}\\d{1,3}" matches "Ryan1" and "Sam99" as readily as "H10", + /// so a first name with a digit passed through untouched. A pattern cannot be an allowlist - the + /// moment a rule describes a SHAPE rather than a known value it admits everything else of that + /// shape. Model codes are therefore listed one by one. + /// + /// The cost is that an unlisted device logs as "" until its code is added, which is the right + /// direction to fail: a missing model is an inconvenience, a leaked name is not. + /// + /// Anything not on this list is DROPPED, which is the point: a naming shape nobody anticipated loses + /// by default. Kotlin twin: `SAFE_DEVICE_NAME_TOKEN_RE`. + private static let safeDeviceNameToken = try? NSRegularExpression( + pattern: "^(whoop|mg|polar|verity|sense|wahoo|tickr|garmin|hrm|forerunner|fenix|vantage|ignite|amazfit|huami|zepp|xiaomi|mi|band|coospo|magene|suunto|scosche|rhythm|kickr|tacx|elite|cateye|decathlon|kalenji|geonaute|h6|h7|h9|h10|h64|h808s|oh1|dual|\\d+(\\.\\d+)?)$", options: [.caseInsensitive]) + + /// A device name reduced to what is safe to put in a shared log: the MODEL, never the person. + /// + /// WHOOP seeds a strap's name from the account holder ("'s Whoop") and people rename + /// straps to anything at all. `redactPii` can only GUESS which words in a line are a name; here the + /// whole string IS the advertised name, so the safe move is an ALLOWLIST - keep the tokens known to + /// name a model and drop everything else. A naming shape nobody anticipated is then dropped by + /// default rather than needing a rule to catch it: "Ryan B's WHOOP 4.0" keeps only "WHOOP 4.0", and + /// "Dad's spare" keeps nothing. + /// + /// The "no name advertised" sentinel survives, because "we saw no name" and "we removed a name" are + /// different facts to whoever reads the log. Kotlin twin: `logSafeDeviceName`. + nonisolated static func logSafeDeviceName(_ name: String?) -> String { + let n = (name ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if n.isEmpty || n == "unknown" { return "unknown" } + let tokens = n.split(whereSeparator: { $0.isWhitespace }) + let safe = tokens.filter { tok in + guard let re = Self.safeDeviceNameToken else { return false } + let t = String(tok) + return re.firstMatch(in: t, range: NSRange(location: 0, length: (t as NSString).length)) != nil + } + // Say "" only when something was actually removed. An unrenamed "WHOOP 4.0" or "Polar H10" + // carries nothing personal, and prefixing it would claim a redaction that never happened. + if safe.count == tokens.count { return n } + return safe.isEmpty ? "" : " " + safe.joined(separator: " ") + } + + private static let hexRunRegex = try? NSRegularExpression(pattern: "[0-9a-fA-F]{16,}") + nonisolated static func redactPii(_ s: String) -> String { var out = s + // Hex first: the text rules below must not see (or mangle) a run we are about to mask. + if let re = Self.hexRunRegex { + let ns = out as NSString + let matches = re.matches(in: out, range: NSRange(location: 0, length: ns.length)) + if !matches.isEmpty { + var rebuilt = "" + var last = 0 + for m in matches { + rebuilt += ns.substring(with: NSRange(location: last, length: m.range.location - last)) + rebuilt += Self.redactHexDump(ns.substring(with: m.range)) + last = m.range.location + m.range.length + } + rebuilt += ns.substring(from: last) + out = rebuilt + } + } out = out.replacingOccurrences( of: "([0-9A-Fa-f]{2}):[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:([0-9A-Fa-f]{2})", with: "$1:••:••:••:••:$2", options: .regularExpression) + // #1193 field capture: the old rule required a DIGIT straight after "WHOOP ", but real serials + // start with letters as often as digits - "WHOOP MGB0779473" sat unredacted in a log attached to + // an issue while "WHOOP 4C1594026" beside it was masked. The rule now accepts any alnum run of 6+ + // that CONTAINS a digit. + // + // The digit requirement is not decoration, it is what keeps this from eating words: "WHOOP PUFFIN + // service 1150" is a real diagnostic line, and PUFFIN is six alnum characters. A serial always + // carries a digit; a word does not. "WHOOP 4.0" stays untouched for a different reason - the dot + // stops the run at one character, short of the six the lookahead demands. out = out.replacingOccurrences( - of: "WHOOP (\\d[0-9A-Za-z]{5,})", with: "WHOOP ", options: .regularExpression) + of: "WHOOP (?=[0-9A-Za-z]{6,})[0-9A-Za-z]*[0-9][0-9A-Za-z]*", with: "WHOOP ", options: .regularExpression) // Mask a CoreBluetooth peripheral UUID, but NOT a standard-BLE / WHOOP-vendor service UUID. out = out.replacingOccurrences( of: "(?![0-9A-Fa-f]{8}-(?:0000-1000-8000-00805f9b34fb|8d6d-82b8-614a-1c8cb0f8dcc6))[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", with: "", options: [.regularExpression, .caseInsensitive]) + // #1303: an ADOPTED device id (`whoop-`) is a device identifier in every line that prints + // an id. Neither rule above catches it — the MAC rule wants MAC shape and the serial rule wants the + // literal "WHOOP " then a DIGIT, while an adopted id is `whoop-` + a serial commonly starting with + // a letter. Keeps three characters, matching `WhoopSerialIdentity.logSafe`, so two straps stay + // distinguishable; PRESERVES the `-noop` computed-sibling suffix, which is not identifying and is + // what lets a reader tell derived rows from measured ones. Six-character minimum matches + // `minSerialLength`, so `my-whoop` and `my-whoop-noop` are untouched. Kotlin twin in + // `redactStrapLogPii`. + out = out.replacingOccurrences( + of: "whoop-([A-Za-z0-9]{3})[A-Za-z0-9-]{3,}(-noop)", + with: "whoop-$1…$2", options: .regularExpression) + out = out.replacingOccurrences( + of: "whoop-([A-Za-z0-9]{3})[A-Za-z0-9-]{3,}", + with: "whoop-$1…", options: .regularExpression) + // The account holder's NAME, as WHOOP writes it into the advertised local name. WHOOP names a + // strap "'s Whoop" by default and the scan path logs that name on every discovery, so + // the shareable log (#445) we ask people to attach to public issues carried a real person's name. + // No rule above could see it: they key on MAC shape, "WHOOP " + digit, or a "whoop-" id. + // + // Keeps the possessive and whatever follows, so "Ryan's WHOOP 4.0" keeps the MODEL, which is + // diagnostic and identifies nobody. Matches the curly apostrophe because Apple platforms write + // U+2019 into default device names — a straight-quote-only rule would miss this platform's logs. + // + // LIMITATION, deliberate: exactly ONE token before the possessive, so "Ryan B's Whoop" keeps + // "Ryan". A multi-token rule cannot tell a name from the surrounding log text and would swallow + // "Discovered" with it. A fully custom name with no possessive stays a known gap. Kotlin twin in + // `redactStrapLogPii` as `PII_DEVICE_NAME_RE`. + out = out.replacingOccurrences( + of: "[\\p{L}\\p{N}_.\\-]+(['\u{2019}]s\\s+(?i:whoop))", + with: "$1", options: .regularExpression) return out } diff --git a/Strand/BLE/OuraLiveSource.swift b/Strand/BLE/OuraLiveSource.swift index 58f037ce44..b0fd228409 100644 --- a/Strand/BLE/OuraLiveSource.swift +++ b/Strand/BLE/OuraLiveSource.swift @@ -13,6 +13,30 @@ import UIKit import AppKit #endif +/// The label a discovered ring is listed under: its advertised local name, or `fallback` when it did +/// not advertise one. +/// +/// A BONDED ring routinely stops advertising a local name, so the blank case is the NORMAL case for the +/// ring a user most wants to reconnect to (#1776), not an error path — the scan's service filter is the +/// only gate and the name is a label. Callers therefore never see an empty `DiscoveredRing.name`, which +/// is why the wizard renders it unguarded on both platforms (#1783 reads that as a missing guard; the +/// guard is here, at the single construction site). +/// +/// BLANK, not empty. The Kotlin twin is `ouraAdvertisedLabel` in `ble/OuraLiveSource.kt`, built on +/// `ifBlank`, which treats an all-whitespace name as absent. `isEmpty` does not, so a ring advertising +/// `" "` would have listed blank here and as "Oura" there — the exact silent Swift/Kotlin divergence the +/// parity contract calls out. Both sides now treat whitespace as absent and return the name UNTRIMMED +/// when it is present. Pinned by the Kotlin `OuraNamelessRingDiscoveryTest` against this function's own +/// output. The two agree over ASCII whitespace, ordinary names, and U+00A0. `ifBlank` does NOT test +/// `Character.isWhitespace`, which would indeed exclude U+00A0; it tests Kotlin's `Char.isWhitespace`, +/// defined as `Character.isWhitespace(c) || Character.isSpaceChar(c)`, and the second disjunct restores +/// every non-breaking space the first drops. The pair parts only on U+0085 (blank here, not in Kotlin) +/// and U+001C–U+001F (blank in Kotlin, not here), which no BLE local name carries — stated rather than +/// silently overclaimed. +func ouraAdvertisedLabel(_ advertisedName: String, fallback: String) -> String { + advertisedName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? fallback : advertisedName +} + /// EXPERIMENTAL, ISOLATED live-BLE source for the Oura ring (gen 3/4/5), driven by the clean-room /// `OuraProtocol.OuraDriver`. /// @@ -221,6 +245,13 @@ public final class OuraLiveSource: NSObject, ObservableObject { private var loggedFirstTemp = false /// Logs the FIRST SpO2 sample decoded this session only. Twin of `loggedFirstTemp`. private var loggedFirstSpo2 = false + /// The 0x13 SyncTime reply parked because nothing yet available could disambiguate its unit (ticks vs + /// seconds x10): the resume cursor was 0 (fresh pair / post-reboot full pull) or so stale the ring's + /// clock had run past the window. Retried against the drain's `maxSeenRingTime` as the first batch + /// lands (2026-09-02/03 captures). The ORIGINAL receipt wall-clock is carried along, because THAT is the instant the + /// ring's counter pairs with - re-stamping at retry time would skew the anchor by the drain's latency. + private var pendingSyncTime: (deviceTimestamp: UInt32, status: UInt8, receivedAt: Int64)? + /// Logs the FIRST ring-time -> UTC anchor of this session only (s5.5); reset on stop/disconnect. private var loggedAnchor = false /// Tier-B (UNVERIFIED) kinds ("activity" / "real_steps" / "sleep_summary" / "spo2_smoothed") already @@ -1304,6 +1335,7 @@ public final class OuraLiveSource: NSObject, ObservableObject { loggedFirstTemp = false loggedFirstSpo2 = false loggedAnchor = false + pendingSyncTime = nil loggedTierBKinds.removeAll() loggedFeatureStatuses.removeAll() loggedProductInfo.removeAll() @@ -1397,8 +1429,10 @@ public final class OuraLiveSource: NSObject, ObservableObject { fetchHistoryIfIdle() // pull last night's banked temp/SpO2/HRV/sleep-phase right away write([OuraCommands.getBattery()]) // ask once HR streams; the 0x0D reply routes to onBattery // Read-only diagnostic: ask the ring its SpO2 / real-steps feature status once, so a capture - // confirms (from the ring itself) that these server-flag features are subscription-gated OFF - // for an offline ring. NEVER an enable/set-mode write - purely the 0x20 read verb. + // sees the ring's own gate state. NOT reliably "off" for an offline ring — on-device + // 2026-08-25 real_steps read back status=1 (enabled) here, matching the real Oura app's read + // of the same ring byte-for-byte (worklog analysis/2026-08-25-noop-ring-hci-realsteps- + // confirmation.txt). NEVER an enable/set-mode write - purely the 0x20 read verb. write([OuraCommands.spo2ReadStatus(), OuraCommands.realStepsReadStatus()]) // Read-only capture (#771/#772): the ring's GetProductInfo serial + hardware pages are // pre-auth readable. The SERIAL is a STABLE per-ring identity — unlike the CoreBluetooth UUID, @@ -1539,6 +1573,10 @@ public final class OuraLiveSource: NSObject, ObservableObject { drain.noteSeenRingTime(rt) } } + // The drain now has a ring-time reference that needs no anchor, so a 0x13 reply parked at connect + // may be resolvable. Retry BEFORE `ingest`, so this batch anchors straight away instead of parking + // into `pendingAnchorEvents` and being drained a moment later (2026-09-02/03 captures). + retryPendingSyncTimeAnchor() if pendingContinuation { restartBatchQuietTimer() } ingest(events) } @@ -2199,15 +2237,20 @@ extension OuraLiveSource: @preconcurrency CBCentralManagerDelegate { rssi RSSI: NSNumber) { let advName = advertisementData[CBAdvertisementDataLocalNameKey] as? String let name = advName ?? peripheral.name ?? "" - // The scan already filters on the Oura service, but re-check the name through the gen recogniser - // so a coincidental service match without an Oura-shaped name is dropped (best-effort). + // Best-effort generation guess from the advertised name — a LABEL only (the wizard falls back to + // .gen3 when it is nil). Nothing is dropped here: the scan's service filter is the only gate, so a + // ring is listed even when it advertises no local name at all. That is deliberate — a BONDED ring + // often stops advertising one, and treating the empty name as a miss is the bug the Android twin + // carried (`ExperimentalBrand.recognise(name) != OURA` in `ble/OuraLiveSource.kt`, where + // `recognise("")` is nil) until it was brought in line with this side. Do not "restore" a + // name-based drop on either platform. let detectedGen = OuraRingGen.recognise(advertisedName: name) let id = peripheral.identifier let firstSight = seenPeripherals[id] == nil seenPeripherals[id] = peripheral - if firstSight { log("Oura: found \(name.isEmpty ? "Oura ring" : name) (\(id)) rssi \(RSSI.intValue)") } + if firstSight { log("Oura: found \(ouraAdvertisedLabel(name, fallback: "Oura ring")) (\(id)) rssi \(RSSI.intValue)") } let ring = DiscoveredRing(id: id, - name: name.isEmpty ? "Oura" : name, + name: ouraAdvertisedLabel(name, fallback: "Oura"), rssi: RSSI.intValue, detectedGen: detectedGen) if let idx = discovered.firstIndex(where: { $0.id == id }) { @@ -2246,6 +2289,7 @@ extension OuraLiveSource: @preconcurrency CBCentralManagerDelegate { loggedFirstTemp = false loggedFirstSpo2 = false loggedAnchor = false + pendingSyncTime = nil loggedTierBKinds.removeAll() loggedFeatureStatuses.removeAll() loggedProductInfo.removeAll() @@ -2331,6 +2375,7 @@ extension OuraLiveSource: @preconcurrency CBCentralManagerDelegate { loggedFirstTemp = false loggedFirstSpo2 = false loggedAnchor = false + pendingSyncTime = nil loggedTierBKinds.removeAll() loggedFeatureStatuses.removeAll() loggedProductInfo.removeAll() @@ -2516,28 +2561,56 @@ extension OuraLiveSource: @preconcurrency CBPeripheralDelegate { ingestHistory(driver.ingest(notification: bytes, reassembler: reassembler)) } + /// The ring-time floor the 0x13 unit test disambiguates against: the persisted resume cursor, or the + /// largest envelope ring-time this drain has seen when that is further along. `maxSeenRingTime` is the + /// half that breaks the deadlock (2026-09-02/03 captures) — it counts EVERY history record, anchored or not, so it is + /// readable with no anchor, whereas the cursor can only advance once an anchor exists. + private var syncTimeAnchorLowerBound: UInt32 { + max(historyCursor, drain.maxSeenRingTime) + } + + /// Try to turn a 0x13 SyncTime reply into this session's UTC anchor, returning whether it stuck. + /// `receivedAt` is the host wall-clock at the moment the reply ARRIVED (not now), since that is the + /// instant the ring's counter pairs with. Shared by the at-connect attempt and the retry. + private func adoptSyncTimeAnchor(deviceTimestamp: UInt32, status: UInt8, receivedAt: Int64, source: String) -> Bool { + guard let driver else { return false } + guard let rt = OuraDriver.syncTimeAnchorCandidate(responseValue: deviceTimestamp, + lowerBoundTicks: syncTimeAnchorLowerBound), + driver.adoptSyncTimeAnchor(ringTimestamp: rt, unixSeconds: receivedAt) else { return false } + let unit = rt == deviceTimestamp ? "ticks" : "seconds x10" + let raw = String(format: "0x%08x", deviceTimestamp) + if !loggedAnchor { + loggedAnchor = true + log("Oura: UTC anchor from SyncTime response (0x13) \(source) - device rt \(rt) [\(unit), raw \(raw), status \(status)] = its receipt time; no 0x42 needed this session") + } + drainPendingAnchorEvents() + drainPendingHypnogramBursts() + return true + } + /// Anchor from the 0x13 SyncTime response (ringverse BLE.md `13 05 `): /// the ring's clock counter at the moment it processed our SyncTime, paired with the host wall-clock - /// at receipt. The tick unit is disambiguated against the persisted resume cursor - /// (OuraDriver.syncTimeAnchorCandidate — raw ticks vs seconds×10, exactly one must be plausible); - /// no unambiguous reading → log the raw value for investigation and adopt NOTHING (an honest missing - /// anchor beats a guessed one). On success everything parked while unanchored resolves immediately. + /// at receipt. The tick unit is disambiguated against a known-earlier ring-time + /// (OuraDriver.syncTimeAnchorCandidate — raw ticks vs seconds×10, exactly one must be plausible). + /// At connect the only reference is the resume cursor, which is 0 on a fresh pair and may be far + /// staler than the ring's clock; rather than discard the reply, PARK it and retry once history starts + /// landing (2026-09-02/03 captures). Still adopts NOTHING on ambiguity — an honest missing anchor beats a guessed one. private func handleSyncTimeResponse(_ resp: (deviceTimestamp: UInt32, status: UInt8)) { - guard let driver else { return } let now = Int64(Date().timeIntervalSince1970) + if adoptSyncTimeAnchor(deviceTimestamp: resp.deviceTimestamp, status: resp.status, + receivedAt: now, source: "at connect") { return } + pendingSyncTime = (resp.deviceTimestamp, resp.status, now) let raw = String(format: "0x%08x", resp.deviceTimestamp) - if let rt = OuraDriver.syncTimeAnchorCandidate(responseValue: resp.deviceTimestamp, - historyCursor: historyCursor), - driver.adoptSyncTimeAnchor(ringTimestamp: rt, unixSeconds: now) { - let unit = rt == resp.deviceTimestamp ? "ticks" : "seconds x10" - if !loggedAnchor { - loggedAnchor = true - log("Oura: UTC anchor from SyncTime response (0x13) - device rt \(rt) [\(unit), raw \(raw), status \(resp.status)] = now; no 0x42 needed this session") - } - drainPendingAnchorEvents() - drainPendingHypnogramBursts() - } else { - log("Oura: SyncTime response (0x13) raw \(raw) status \(resp.status) - no unambiguous tick reading vs cursor \(historyCursor); anchor NOT adopted (investigation)") + log("Oura: SyncTime response (0x13) raw \(raw) status \(resp.status) - no unambiguous tick reading vs ring-time floor \(syncTimeAnchorLowerBound); parked, retrying as history lands (investigation)") + } + + /// Retry a parked 0x13 reply now that the drain has seen real ring-times. Silent on failure (the + /// parked reply simply waits for a better floor); one line on success, from `adoptSyncTimeAnchor`. + private func retryPendingSyncTimeAnchor() { + guard let parked = pendingSyncTime, drain.maxSeenRingTime > 0 else { return } + if adoptSyncTimeAnchor(deviceTimestamp: parked.deviceTimestamp, status: parked.status, + receivedAt: parked.receivedAt, source: "resolved against history") { + pendingSyncTime = nil } } diff --git a/Strand/BLE/PuffinDeepBufferLog.swift b/Strand/BLE/PuffinDeepBufferLog.swift index 56b0dff2e3..07c919da26 100644 --- a/Strand/BLE/PuffinDeepBufferLog.swift +++ b/Strand/BLE/PuffinDeepBufferLog.swift @@ -34,9 +34,9 @@ enum PuffinOpticalExperimentPhase: String, CaseIterable, Sendable { /// packets that carry tens-of-Hz sensor data (motion + optical) rather than the 1 Hz historical /// rollup NOOP already decodes (#423). /// -/// On-strap probing (#423) established that the 5/MG has no live raw-IMU stream, but it DOES bank -/// high-rate motion (accel content verified: int16-ish triples sphere-fitting to ~1 g at the 1/4096 -/// scale) and ships it inside big type-0x2F buffers during the connect-time offload burst — 124 B +/// On-strap probing (#423) established that the 5/MG banks high-rate motion and can stream it live +/// during an explicitly armed raw-data session (accel content verified: int16-ish triples +/// sphere-fitting to ~1 g at the 1/4096 scale). History also ships it inside big type-0x2F buffers — 124 B /// (≈1 Hz record), 1244 B and 2140 B (≈32 and ≈59 sub-records per timestamped second). NOOP's /// historical decoder pulls the 1 Hz gravity vector out and DISCARDS the high-rate remainder. Those /// bursts are ephemeral and backlog-gated, so a byte-perfect decoder can't be captured on demand — it diff --git a/Strand/BLE/SourceCoordinator.swift b/Strand/BLE/SourceCoordinator.swift index 5b5e87d4cd..a8f2fb1870 100644 --- a/Strand/BLE/SourceCoordinator.swift +++ b/Strand/BLE/SourceCoordinator.swift @@ -588,7 +588,9 @@ final class SourceCoordinator: ObservableObject { } /// A device is WHOOP when its brand is "WHOOP" (the seeded `my-whoop` row's brand). - static func isWhoop(_ device: PairedDevice) -> Bool { - device.id == "my-whoop" || device.brand.caseInsensitiveCompare("WHOOP") == .orderedSame - } + /// + /// #1881 gave the BLE engine the same question to answer, so the rule now lives in ONE place + /// (`SourceIdentity`, beside `PairedDevice`) and this delegates. Two spellings of "is this a WHOOP" + /// that could disagree is precisely how a strap's samples end up filed under a ring. + static func isWhoop(_ device: PairedDevice) -> Bool { SourceIdentity.isWhoop(device) } } diff --git a/Strand/BLE/StandardHRSource.swift b/Strand/BLE/StandardHRSource.swift index 424f7c0c04..1fda83e60d 100644 --- a/Strand/BLE/StandardHRSource.swift +++ b/Strand/BLE/StandardHRSource.swift @@ -126,9 +126,9 @@ public final class StandardHRSource: NSObject, ObservableObject { // MARK: - Sample buffer - /// Buffered (hr, rr, ts) readings, flushed to `persist` in batches to keep the write path off + /// Buffered (hr, rr, contact, ts) readings, flushed to `persist` in batches to keep the write path off /// the per-notification hot loop. - private var buffer: [(hr: Int, rr: [Int], ts: Int)] = [] + private var buffer: [(hr: Int, rr: [Int], contact: StandardHRContact, ts: Int)] = [] private var lastFlush: Date = .init() /// Flush thresholds — whichever trips first. private let flushCount = 30 @@ -230,8 +230,8 @@ public final class StandardHRSource: NSObject, ObservableObject { // MARK: - Buffer / persistence - private func enqueue(hr: Int, rr: [Int]) { - buffer.append((hr: hr, rr: rr, ts: Int(Date().timeIntervalSince1970))) + private func enqueue(hr: Int, rr: [Int], contact: StandardHRContact) { + buffer.append((hr: hr, rr: rr, contact: contact, ts: Int(Date().timeIntervalSince1970))) if buffer.count >= flushCount || Date().timeIntervalSince(lastFlush) >= flushInterval { flush() } @@ -240,7 +240,8 @@ public final class StandardHRSource: NSObject, ObservableObject { private func flush() { guard !buffer.isEmpty else { lastFlush = Date(); return } for sample in buffer { - persist(StandardHRMapping.samples(fromHR: sample.hr, rr: sample.rr, at: sample.ts)) + persist(StandardHRMapping.samples(fromHR: sample.hr, rr: sample.rr, + contact: sample.contact, at: sample.ts)) } buffer.removeAll() lastFlush = Date() @@ -304,7 +305,7 @@ extension StandardHRSource: @preconcurrency CBCentralManagerDelegate { seenPeripherals[id] = peripheral let advName = advertisementData[CBAdvertisementDataLocalNameKey] as? String let name = advName ?? peripheral.name ?? "Heart Rate Strap" - if firstSight { log("HR-strap: found \(name) (\(id)) rssi \(RSSI.intValue)") } + if firstSight { log("HR-strap: found \(LiveState.logSafeDeviceName(name)) (\(id)) rssi \(RSSI.intValue)") } let strap = DiscoveredStrap(id: id, name: name, rssi: RSSI.intValue) discovered = Self.upsertByProximity(discovered, strap) // If we were scanning specifically to reach this strap (a not-yet-cached active strap), connect now @@ -478,6 +479,6 @@ extension StandardHRSource: @preconcurrency CBPeripheralDelegate { live.heartRate = parsed.hr live.setRRIntervals(parsed.rr) live.connected = true - enqueue(hr: parsed.hr, rr: parsed.rr) + enqueue(hr: parsed.hr, rr: parsed.rr, contact: parsed.contact) } } diff --git a/Strand/BLE/StandardHeartRate.swift b/Strand/BLE/StandardHeartRate.swift index c1badb8bf6..5feb063dd0 100644 --- a/Strand/BLE/StandardHeartRate.swift +++ b/Strand/BLE/StandardHeartRate.swift @@ -1,11 +1,13 @@ import Foundation +import WhoopProtocol /// Pure parser for the standard BLE Heart Rate Measurement characteristic (0x2A37). -/// Returns the heart rate (bpm) and any R-R intervals (ms). Pure → unit-testable. +/// Returns the heart rate (bpm), R-R intervals (ms), and the contact state. Pure → unit-testable. public enum StandardHeartRate { - public static func parse(_ data: [UInt8]) -> (hr: Int, rr: [Int])? { + public static func parse(_ data: [UInt8]) -> (hr: Int, rr: [Int], contact: StandardHRContact)? { guard !data.isEmpty else { return nil } let flags = data[0] + let contact = StandardHRContact.fromMeasurementFlags(flags) var idx = 1 let hr: Int if flags & 0x01 != 0 { // 16-bit HR @@ -24,6 +26,6 @@ public enum StandardHeartRate { idx += 2 } } - return (hr, rr) + return (hr, rr, contact) } } diff --git a/Strand/Collect/Backfiller.swift b/Strand/Collect/Backfiller.swift index 1f78b47a67..59993ebf69 100644 --- a/Strand/Collect/Backfiller.swift +++ b/Strand/Collect/Backfiller.swift @@ -48,6 +48,11 @@ final class Backfiller { /// of end_data, used for the `strap_trim` cursor) and the 8-byte `end_data` (= the raw /// HISTORY_END metadata.data[10:18]) that the high-freq-sync ack form requires verbatim. private let ackTrim: (_ trim: UInt32, _ endData: [UInt8]) -> Void + /// #1635: one offload chunk's accepted-row counts, handed up so `BLEManager` can tally them per LINK. + /// The offload is the only path that banks gravity/resp/skinTemp/SpO2/steps, so a link summary + /// without it cannot tell an unbonded strap — which defers backfill — from a healthy one. + private let onBankedOffload: (_ counts: (hr: Int, rr: Int, events: Int, battery: Int, + spo2: Int, skinTemp: Int, resp: Int, gravity: Int)) -> Void private let extract: Extractor /// Research toggle. When false (DEFAULT) no raw frames are persisted — the chunk's /// decoded streams are still durable and the trim is still acked (decoded is the product of @@ -142,6 +147,11 @@ final class Backfiller { /// Logged once per session when the strap reports trim=0xFFFFFFFF — the "no valid flash cursor" /// sentinel: it has no banked history to offload (a clock/charge state, not a decode bug). private var loggedNoCursor = false + /// #1754: whether THIS session saw the trim=0xFFFFFFFF "no valid flash cursor" sentinel. Exposed so + /// the empty-offload banner can distinguish the clock/charge state (no cursor — the existing copy is + /// correct) from a strap that has a valid, advancing flash cursor but banks no sensor records (NOT a + /// clock problem — points at the sensor front-end or power). Read-only from outside. + var sawNoFlashCursor: Bool { loggedNoCursor } /// #773: logged once per session the first time a HISTORY_END's own timestamp is dated implausibly far /// in the FUTURE (a corrupt strap RTC). Distinct from #547's per-record drop tally: this fires on the /// chunk metadata's own clock, the earliest visible tell that the strap's RTC is bogus. Reset in begin(). @@ -209,6 +219,9 @@ final class Backfiller { init(store: BackfillStoreWriting, deviceId: String, ackTrim: @escaping (_ trim: UInt32, _ endData: [UInt8]) -> Void, + onBankedOffload: @escaping (_ counts: (hr: Int, rr: Int, events: Int, battery: Int, + spo2: Int, skinTemp: Int, resp: Int, + gravity: Int)) -> Void = { _ in }, enableRawCapture: Bool = false, log: ((String) -> Void)? = nil, rejectedSink: ((_ frames: [[UInt8]], _ trim: UInt32, _ family: DeviceFamily) -> Bool)? = nil, @@ -225,6 +238,7 @@ final class Backfiller { self.store = store self.deviceId = deviceId self.ackTrim = ackTrim + self.onBankedOffload = onBankedOffload self.enableRawCapture = enableRawCapture self.log = log self.rejectedSink = rejectedSink @@ -428,6 +442,69 @@ final class Backfiller { return "Backfill: the strap reported a record dated about \(aheadDays) day(s) in the FUTURE - its clock (RTC) is corrupt, not a NOOP problem. Those records can't be filed onto the right day. Fully charge the strap to 100% and reconnect so it re-syncs its clock; if it persists, forget and re-pair the strap." } + /// #1683: how far BEHIND the wall clock the strap's newest stored record may sit before a sync that + /// banked nothing is worth explaining. Two days, not one: a strap left off-wrist overnight is ordinary, + /// and this line only ever accompanies a completed offload that banked nothing anyway. + nonisolated static let staleRecordToleranceSeconds = 2 * 86_400 + + /// Is the strap's newest stored record far enough in the past to be worth naming? A nil or + /// non-positive value is not a date and never qualifies. + nonisolated static func isStaleNewestRecord(newestUnix: Int?, wallNowUnix: Int) -> Bool { + guard let newestUnix, newestUnix > 0 else { return false } + return newestUnix <= wallNowUnix - staleRecordToleranceSeconds + } + + /// #1683: the counterpart `futureRtcLine` never had. A strap that stopped banking weeks ago and one + /// that is simply caught up produce the SAME "banked no sensor history" line today, so neither the user + /// nor anyone reading their log can tell them apart. #1541 stayed open and vague for exactly that + /// reason. + /// + /// Deliberately states the FACT and lets the condition carry the interpretation. A newest record two + /// weeks old means the strap stopped recording IF it was being worn; if it sat in a drawer, the same + /// number is unremarkable. Asserting a corrupt RTC here would claim more than the data supports, which + /// is how this area has misled people before. + /// + /// It also says the part the existing advice omits: NOOP re-sends SET_CLOCK on every connect, so + /// "charge it" alone has already been retried every session. + /// + /// Byte-identical to the Android twin. No em-dash (project rule). + nonisolated static func staleRecordLine(newestUnix: Int, wallNowUnix: Int) -> String { + let ageDays = max(0, wallNowUnix - newestUnix) / 86_400 + return "Backfill: this sync banked nothing and the strap's newest stored record is about \(ageDays) day(s) old. If you have worn it since then, it has stopped saving history to its flash. NOOP already re-sends the clock on every connect, so charging alone may not be enough: charge to 100% and reconnect, then use Restart strap in Devices, and if that does not help forget and re-pair. If the official WHOOP app is also missing these days, the strap is the cause and not NOOP." + } + + + /// #1683: the same honesty as `staleRecordLine`, for the message the user actually READS. + /// + /// The standing banner says "fully charge it to 100%, then reconnect, and it should start banking + /// again". It omits the one fact that makes the situation legible - how long the strap has been + /// silent - and it PROMISES a recovery that has already failed every session for weeks, because NOOP + /// re-sends SET_CLOCK on every connect and the charge advice has therefore been retried all along. A + /// banner that keeps promising something that keeps not happening teaches people to distrust the app + /// rather than their strap. + /// + /// Byte-identical to the Android twin. Not localized, matching the sibling `lastSyncError` copy on + /// both platforms; localizing that surface is its own change. No em-dash (project rule). + nonisolated static func staleRecordBanner(newestUnix: Int, wallNowUnix: Int) -> String { + let ageDays = max(0, wallNowUnix - newestUnix) / 86_400 + return "Synced, but your strap handed over no stored history, and its newest saved record is about \(ageDays) day(s) old. If you have been wearing it since then, it has stopped saving to flash. Charge it to 100% and reconnect; NOOP already re-sets its clock every connect, so if that does not help, try Restart strap in Devices, then forget and re-pair. If the official WHOOP app is missing these days too, the strap is the cause and not NOOP." + } + + /// #1754: the banner for an empty offload whose flash cursor is VALID and ADVANCING — the strap is + /// writing pages but banking no sensor records, so the clock/charge advice does not apply. The + /// cause points at the sensor front-end or power state, not the RTC. Byte-identical to the Android + /// twin (`Backfiller.noSensorRecordsBanner`). Not localized, matching the sibling `lastSyncError` + /// copy on both platforms; localizing that surface is its own change. No em-dash (project rule). + nonisolated static let noSensorRecordsBanner = + "Synced, but your strap handed over no sensor records - only its diagnostic output. The strap's flash cursor is valid and advancing, so this is not a clock problem; it points at the sensor front-end or power state. If this persists across reconnects, please share a strap log so the cause can be identified." + + /// #1754: the banner for an empty offload whose flash cursor is the 0xFFFFFFFF sentinel — the strap + /// has no banked history at all, the clock/charge advice IS correct. Kept as a constant so the two + /// banners stay side by side and the caller's branch reads as a choice between two named states. + /// Byte-identical to the Android twin (`Backfiller.noFlashCursorBanner`). + nonisolated static let noFlashCursorBanner = + "Synced, but your strap had no stored history to hand over - only its diagnostic output. This usually means its clock has lost sync, so it isn't saving data to flash. Fully charge it to 100%, then reconnect, and it should start banking again." + /// Commit one HISTORY_END chunk: (persist decoded → enqueueRaw when present) → setCursor → ackTrim. /// Early-returns on any throw to preserve the safe-trim invariant. /// @@ -658,7 +735,10 @@ final class Backfiller { // emission can be measured, since every existing R-R number is taken after the ON CONFLICT key // has already absorbed part of it. let rrCensus = RrEmissionStats.compute(decoded.rr.map { (ts: $0.ts, rrMs: $0.rrMs) }) - do { counts = try await store.insert(decoded, deviceId: deviceId) } catch { + do { + counts = try await store.insert(decoded, deviceId: deviceId) + onBankedOffload(counts) + } catch { // Diag (#601): the decoded rows couldn't be written — this is the "history stalls but live HR // works" class. We return WITHOUT acking so the strap keeps this chunk and re-sends it next // session (no data loss), but a silent return left a strap log with no trace of the stall. diff --git a/Strand/Collect/ClockCorrelation.swift b/Strand/Collect/ClockCorrelation.swift index 9d16d366fa..9188ff61de 100644 --- a/Strand/Collect/ClockCorrelation.swift +++ b/Strand/Collect/ClockCorrelation.swift @@ -8,8 +8,19 @@ import WhoopStore /// No CoreBluetooth, no I/O — fully unit-testable. enum ClockCorrelation { /// Build a `ClockRef` from a decoded GET_CLOCK COMMAND_RESPONSE frame and the wall - /// time observed when the response arrived. Returns nil unless the frame parsed OK, - /// passed CRC, and carries a `clock` value. + /// time observed when the response arrived. Returns nil unless the frame is INTACT and carries a + /// `clock` value. + /// + /// `ok` is the verifier's full verdict — header checksum, payload CRC32 and structural length + /// together — so one condition now covers what the two-part check used to. The stake is why this + /// gate is worth stating plainly: the anchor set here converts every live device timestamp into wall + /// time, and until it is set the Collector and the Backfiller both hold their rows. An anchor taken + /// from a damaged frame does not block persistence, it timestamps everything wrongly, which is worse + /// — so an unverifiable frame leaves the anchor unset and the downstream storage blocked. + /// + /// The `crcOK` condition is kept for parse results that did not come from a fresh parse: a + /// `ParsedFrame` decoded from a capture written before the verdict widened carries the old constant + /// `ok: true` beside a false `crcOK`. static func clockRef(from parsed: ParsedFrame, wall: Int) -> ClockRef? { guard parsed.ok, parsed.crcOK != false, let device = parsed.parsed["clock"]?.intValue else { return nil } diff --git a/Strand/Collect/Collector.swift b/Strand/Collect/Collector.swift index 87279bfa0a..e54b2bfea5 100644 --- a/Strand/Collect/Collector.swift +++ b/Strand/Collect/Collector.swift @@ -1,6 +1,7 @@ import Foundation import WhoopProtocol import WhoopStore +import StrandAnalytics /// The subset of WhoopStore the Collector needs. A protocol so tests can inject a spy /// (WhoopStore is `final`). WhoopStore conforms via the extension below. @@ -12,12 +13,6 @@ protocol StoreWriting: AnyObject { -> (hr: Int, rr: Int, events: Int, battery: Int, spo2: Int, skinTemp: Int, resp: Int, gravity: Int) func enqueueRawBatch(_ meta: RawBatchMeta, frames: [[UInt8]]) async throws - func insertRawImu(deviceId: String, rows: [(ts: Int, cols: [Int16])], retentionRows: Int) async throws -} -extension StoreWriting { - /// #423: default no-op so a test SpyStore needn't implement the raw-IMU capture path. WhoopStore's - /// real impl (StreamStore.swift) satisfies the requirement and is used in production. - func insertRawImu(deviceId: String, rows: [(ts: Int, cols: [Int16])], retentionRows: Int) async throws {} } extension WhoopStore: StoreWriting {} @@ -72,20 +67,67 @@ final class Collector { /// #47: buffer the (raw frame, pre-parsed) pair. The raw bytes are still needed for the raw-capture /// outbox; the parse is the one the BLE seam already did, so `flush` doesn't re-decode the batch. private var buffer: [(frame: [UInt8], parsed: ParsedFrame)] = [] - /// Standard 0x2A37 HR/RR buffer — the reliable, always-on stream, recorded continuously + /// #1118: strap-log sink for the per-transport R-R census. Optional and defaulted to nil so the + /// test fakes that construct a Collector are untouched; `BLEManager` wires its own `log`. + private let log: ((String) -> Void)? + /// #1635: rows ACCEPTED per stream, handed up so `BLEManager` can tally them per LINK and say which + /// streams banked when it writes the link epitaph. The counts already exist — `StreamStore.insert` + /// returns them and the standard-HR path already binds them for its own trace line — so this carries + /// a measurement that was being discarded, rather than taking a new one. + private let onBanked: ((BankedCounts) -> Void)? + /// #1118: last emit of each LIVE census line, unix seconds; 0 = never. Rate-limited — see + /// `RrEmissionStats.shouldEmitLiveCensus`. + /// + /// Lifetime DIVERGES from the Kotlin twin. These reset whenever `BLEManager.bootstrapStore()` + /// rebuilds the Collector (a store rebuild after unlock, among other paths), so a log can carry an + /// extra line after one of those. Android keeps its stamps on the process-wide `WhoopBleClient` + /// singleton, which a device switch mutates rather than rebuilds, so they never reset there. + /// Harmless either way — a rate-limit on a diagnostic, not a measurement — but a reader comparing + /// two logs should not have to work out why one has more lines than the other. + /// + /// @MainActor isolation makes these safe without a lock; the Kotlin fields are deliberately + /// unsynchronized instead, since a stale read there costs one duplicate line. + private var lastStdRrCensusSec: Int = 0 + private var lastRealtimeRrCensusSec: Int = 0 + + /// Consecutive live-persist failures per transport, and when each last reported. + /// + /// Kept PER TRANSPORT because the standard 0x2A37 path and the puffin REALTIME_DATA path (#1118) fail + /// independently — a shared counter would let one path's success reset the other's run and report a + /// persistent failure as a string of first-failures. @MainActor isolation makes these safe without a + /// lock; the Kotlin twin uses AtomicInteger because its two flushes can run concurrently on the io + /// scope, and there the count is the load-bearing distinction between a transient and a run. + private var stdInsertFailures = 0 + private var realtimeInsertFailures = 0 + private var lastStdInsertFailureLogMs: Int64 = 0 + private var lastRealtimeInsertFailureLogMs: Int64 = 0 + + /// Standard 0x2A37 HR/RR/contact buffer — the reliable, always-on stream, recorded continuously /// (independent of the custom realtime stream or which screen is open). private var stdHR: [HRSample] = [] private var stdRR: [RRInterval] = [] + private var stdContact: [WhoopEvent] = [] + /// Last contact state buffered, so only transitions are recorded. See `shouldRecordContact`. + private var lastStdContact: StandardHRContact? private var batchStartedAt: TimeInterval var bufferedCount: Int { buffer.count } + /// The per-stream accepted-row counts `StreamStore.insert` returns, named so the closure that carries + /// them is readable at both ends. + typealias BankedCounts = (hr: Int, rr: Int, events: Int, battery: Int, + spo2: Int, skinTemp: Int, resp: Int, gravity: Int) + init(store: StoreWriting, deviceId: String, policy: CollectorPolicy = .default, enableRawCapture: Bool = false, + log: ((String) -> Void)? = nil, + onBanked: ((BankedCounts) -> Void)? = nil, now: @escaping () -> Int = { Int(Date().timeIntervalSince1970) }, monotonic: @escaping () -> TimeInterval = { Date().timeIntervalSinceReferenceDate }) { self.store = store; self.deviceId = deviceId; self.policy = policy self.enableRawCapture = enableRawCapture + self.log = log + self.onBanked = onBanked self.now = now; self.monotonic = monotonic self.batchStartedAt = monotonic() self.concreteStore = store as? WhoopStore @@ -97,6 +139,28 @@ final class Collector { return try? await s.storageStats() } + /// Decoded history rows in a stable long-form CSV for arbitrary user-selected export windows. + func historySensorsCSV(from: Int, to: Int) async -> Data { + guard let store = concreteStore, from <= to else { return Data("stream,unix_s,v1,v2,v3,v4\n".utf8) } + let limit = min(max(to - from + 1, 1) * 4, 1_000_000) + async let hr = try? store.hrSamples(deviceId: deviceId, from: from, to: to, limit: limit) + async let battery = try? store.batterySamples(deviceId: deviceId, from: from, to: to, limit: limit) + async let spo2 = try? store.spo2Samples(deviceId: deviceId, from: from, to: to, limit: limit) + async let temp = try? store.skinTempSamples(deviceId: deviceId, from: from, to: to, limit: limit) + async let steps = try? store.stepSamples(deviceId: deviceId, from: from, to: to, limit: limit) + async let resp = try? store.respSamples(deviceId: deviceId, from: from, to: to, limit: limit) + async let gravity = try? store.gravitySamples(deviceId: deviceId, from: from, to: to, limit: limit) + var lines = ["stream,unix_s,v1,v2,v3,v4"] + lines += await (hr ?? []).map { "heart_rate,\($0.ts),\($0.bpm),,," } + lines += await (battery ?? []).map { "battery,\($0.ts),\($0.soc.map { String($0) } ?? ""),\($0.mv.map { String($0) } ?? ""),," } + lines += await (spo2 ?? []).map { "spo2_raw,\($0.ts),\($0.red),\($0.ir),," } + lines += await (temp ?? []).map { "skin_temp_raw,\($0.ts),\($0.raw),\($0.aux1Raw.map { String($0) } ?? ""),\($0.aux2Raw.map { String($0) } ?? "")," } + lines += await (steps ?? []).map { "steps,\($0.ts),\($0.counter),\($0.activityClass.map { String($0) } ?? ""),," } + lines += await (resp ?? []).map { "resp_raw,\($0.ts),\($0.raw),,," } + lines += await (gravity ?? []).map { "gravity,\($0.ts),\($0.x),\($0.y),\($0.z),\($0.dynAccel.map { String($0) } ?? "")" } + return Data((lines.joined(separator: "\n") + "\n").utf8) + } + /// Max persisted HR sample ts (the biometric "data frontier" for the stuck-strap watchdog). /// nil if there's no concrete store or nothing persisted yet. Mirrors storageStats(). func latestHRSampleTs() async -> Int? { @@ -128,21 +192,6 @@ final class Collector { ingest(frame: frame, parsed: parseFrame(frame, family: family)) } - /// #423: persist the WHOOP 5/MG raw-IMU offload buffer NOOP already decodes for the deep-buffer log — - /// the queryable twin of that (table-less) diagnostics line. Same `noopPuffinCapture` gate; only the - /// 1244-B 6-axis buffer decodes (rawColumns nil otherwise). Fire-and-forget into the store, bounded by - /// a rolling retention prune. Raw i16, no downstream consumer yet. Twin of Android - /// `WhoopBleClient.storeWhoop5RawImuIfBuffer`. - func storeRawImu(frame: [UInt8]) { - guard UserDefaults.standard.bool(forKey: PuffinFrameRecorder.enabledKey) else { return } - guard let cols = Whoop5RawImu.rawColumns(frame), let baseTs = Whoop5RawImu.baseTs(frame) else { return } - let dev = deviceId - Task { [store] in - try? await store.insertRawImu( - deviceId: dev, rows: [(ts: baseTs, cols: cols)], retentionRows: WhoopStore.rawImuRetentionRows) - } - } - /// Buffer one complete frame + its pre-parsed decode (synchronous: preserves delegate arrival order). /// Auto-flushes via a detached Task when the cadence threshold is hit (flush is async). (#47) func ingest(frame: [UInt8], parsed: ParsedFrame) { @@ -150,6 +199,7 @@ final class Collector { assert(parsed == parseFrame(frame, family: family), "Collector.ingest: threaded ParsedFrame != fresh parse (#47 parse-once invariant)") #endif + recordGroundTruthImu(frame) buffer.append((frame, parsed)) // Pre-clock only: bound memory if GET_CLOCK never lands while data keeps flowing. // Drop OLDEST beyond the cap (keep most recent). Post-clock this branch is skipped — @@ -163,6 +213,7 @@ final class Collector { } } + /// Persist + queue everything buffered. No-op when empty or before a clock ref exists. /// Buffer is snapshotted and cleared SYNCHRONOUSLY before the first await so that any /// concurrent ingest() calls during persistence accumulate into the NEXT batch cleanly. @@ -176,11 +227,38 @@ final class Collector { let frames = batch.map(\.frame) // still needed for the raw-capture outbox let parsed = batch.map(\.parsed) // #47: the seam already decoded these — don't re-parse let streams = extractStreams(parsed, deviceClockRef: ref.device, wallClockRef: ref.wall) + // #1118: the SECOND live transport. `flushStandardHR` stamps a beat at the second it arrived over + // 0x2A37; this one stamps it from the strap's own record clock. The same beat reaching both lands + // on two different seconds, which no same-second de-dup can collapse — the signature every + // affected night prints as `crossSecondOverCount`. + if !streams.rr.isEmpty { + let nowSec = now() + if RrEmissionStats.shouldEmitLiveCensus(lastEmitSec: lastRealtimeRrCensusSec, nowSec: nowSec) { + lastRealtimeRrCensusSec = nowSec + let census = RrEmissionStats.compute(streams.rr.map { (ts: $0.ts, rrMs: $0.rrMs) }) + log?(RrEmissionStats.logLine(path: "live-realtime", offered: streams.rr.count, + inserted: nil, census)) + } + } do { - try await store.insert(streams, deviceId: deviceId) // DECODED FIRST (durable) + let inserted = try await store.insert(streams, deviceId: deviceId) // DECODED FIRST (durable) + realtimeInsertFailures = 0 + onBanked?(inserted) } catch { // Re-buffer at the front so these frames (and their parses) are retried on the next cadence. buffer.insert(contentsOf: batch, at: 0) + // Swallowing this made the census above read like success: a store rejecting everything still + // reported what was OFFERED, with nothing to say none of it landed. + realtimeInsertFailures += 1 + let nowMs = Int64(Date().timeIntervalSince1970 * 1000) + if LivePersistTrace.shouldEmitLiveInsertFailure(lastEmitMs: lastRealtimeInsertFailureLogMs, + nowMs: nowMs) { + lastRealtimeInsertFailureLogMs = nowMs + log?(LivePersistTrace.liveInsertFailedLine( + transport: "live-realtime", errorName: String(describing: type(of: error)), + message: error.localizedDescription, hrFrames: streams.hr.count, + rrFrames: streams.rr.count, consecutiveFailures: realtimeInsertFailures)) + } return } // Reset only after a successful insert so the interval trigger keeps firing if @@ -203,25 +281,78 @@ final class Collector { /// Buffer one standard Heart-Rate-Measurement reading. No clock correlation needed — /// these carry a wall-clock `ts` directly. Auto-flushes ~every 30 readings (~30s). - func ingestStandardHR(hr: Int, rr: [Int], at ts: Int) { - if hr >= 30, hr <= 220 { stdHR.append(HRSample(ts: ts, bpm: hr)) } - for r in rr where r >= 250 && r <= 3000 { stdRR.append(RRInterval(ts: ts, rrMs: r)) } - if stdHR.count + stdRR.count >= 30 { - Task { @MainActor in await self.flushStandardHR() } + func ingestStandardHR(hr: Int, rr: [Int], contact: StandardHRContact? = nil, at ts: Int) { + let acceptedHR = (30...220).contains(hr) ? 1 : 0 + let acceptedRR = rr.filter { (250...3000).contains($0) } + if acceptedHR == 1 { stdHR.append(HRSample(ts: ts, bpm: hr)) } + stdRR.append(contentsOf: acceptedRR.map { RRInterval(ts: ts, rrMs: $0) }) + // Only the CHANGES. Advanced here rather than at flush because the event travels in the buffer + // until it persists: a failed insert re-inserts it at the front, so nothing has to be unwound. + if let contact, StandardHRMapping.shouldRecordContact(previous: lastStdContact, current: contact) { + lastStdContact = contact + stdContact.append(contentsOf: StandardHRMapping.samples( + fromHR: hr, rr: [], contact: contact, at: ts + ).events) + } + log?(LivePersistTrace.standardHRHostReceivedLine( + hostUnixSeconds: ts, + acceptedHRRows: acceptedHR, acceptedRRRows: acceptedRR.count, + rejectedHRRows: 1 - acceptedHR, rejectedRRRows: rr.count - acceptedRR.count, + pendingHRRows: stdHR.count, pendingRRRows: stdRR.count)) + if stdHR.count + stdRR.count + stdContact.count >= 30 { + Task { @MainActor in await self.flushStandardHR(reason: .cadence) } } } - /// Persist the buffered standard HR/RR. Re-buffers on failure so nothing is lost. - func flushStandardHR() async { - guard !stdHR.isEmpty || !stdRR.isEmpty else { return } - let hr = stdHR, rr = stdRR + /// Persist the buffered standard HR/RR/contact. Re-buffers on failure so nothing is lost. + func flushStandardHR(reason: LivePersistTrace.StandardHRFlushReason = .explicit) async { + guard !stdHR.isEmpty || !stdRR.isEmpty || !stdContact.isEmpty else { return } + let hr = stdHR, rr = stdRR, contact = stdContact stdHR.removeAll(keepingCapacity: true) stdRR.removeAll(keepingCapacity: true) + stdContact.removeAll(keepingCapacity: true) + log?(LivePersistTrace.standardHRFlushAttemptLine( + reason: reason, offeredHRRows: hr.count, offeredRRRows: rr.count)) + // #1118: census this batch BEFORE it is stored, exactly as the historical path does, so a strap + // log carries one `ratioRep` per transport. If each transport reports ~1.0 while the stored night + // reads 2.77, the over-count is the UNION of the transports and no single decoder is at fault — + // which is the question this instrumentation exists to settle. + if !rr.isEmpty { + let nowSec = now() + if RrEmissionStats.shouldEmitLiveCensus(lastEmitSec: lastStdRrCensusSec, nowSec: nowSec) { + lastStdRrCensusSec = nowSec + let census = RrEmissionStats.compute(rr.map { (ts: $0.ts, rrMs: $0.rrMs) }) + // `inserted` is NIL, not echoed from `offered`: the store's conflict key decides that and + // this census runs before the insert. The line renders `inserted=n/a`. + log?(RrEmissionStats.logLine(path: "live-standard", offered: rr.count, + inserted: nil, census)) + } + } do { - try await store.insert(Streams(hr: hr, rr: rr), deviceId: deviceId) + let inserted = try await store.insert(Streams(hr: hr, rr: rr, events: contact), deviceId: deviceId) + stdInsertFailures = 0 + onBanked?(inserted) + log?(LivePersistTrace.standardHRFlushSucceededLine( + reason: reason, offeredHRRows: hr.count, offeredRRRows: rr.count, + insertedHRRows: inserted.hr, insertedRRRows: inserted.rr)) } catch { stdHR.insert(contentsOf: hr, at: 0) stdRR.insert(contentsOf: rr, at: 0) + stdContact.insert(contentsOf: contact, at: 0) + stdInsertFailures += 1 + log?(LivePersistTrace.standardHRRebufferedForRetryLine( + reason: reason, attemptedHRRows: hr.count, attemptedRRRows: rr.count, + pendingHRRows: stdHR.count, pendingRRRows: stdRR.count, + consecutiveFailures: stdInsertFailures)) + let nowMs = Int64(Date().timeIntervalSince1970 * 1000) + if LivePersistTrace.shouldEmitLiveInsertFailure(lastEmitMs: lastStdInsertFailureLogMs, + nowMs: nowMs) { + lastStdInsertFailureLogMs = nowMs + log?(LivePersistTrace.liveInsertFailedLine( + transport: "live-standard", errorName: String(describing: type(of: error)), + message: error.localizedDescription, hrFrames: hr.count, rrFrames: rr.count, + consecutiveFailures: stdInsertFailures)) + } } } @@ -233,6 +364,11 @@ final class Collector { rawCapture.open(at: monotonic(), duration: seconds) } + private func recordGroundTruthImu(_ frame: [UInt8]) { + _ = ImuSessionFileStore.shared.append(deviceId: deviceId, frame: frame, + receivedAtMs: Int64(Date().timeIntervalSince1970 * 1_000)) + } + /// Flush WHILE the window is still active so the just-captured frames get persisted as raw, /// THEN close the window. func endRawCapture() async { diff --git a/Strand/Collect/ImuSessionFileStore.swift b/Strand/Collect/ImuSessionFileStore.swift new file mode 100644 index 0000000000..26cf0b254f --- /dev/null +++ b/Strand/Collect/ImuSessionFileStore.swift @@ -0,0 +1,189 @@ +import Compression +import Foundation +import WhoopProtocol + +/// Canonical decoded 100 Hz IMU storage: UTC half-hour files with appendable 30-second zlib blocks. +@MainActor +final class ImuSessionFileStore { + struct Stats { let bytes: Int64; let coveredSeconds: Int; let firstTs: Int64? } + struct ExportSegment { let name: String; let data: Data; let startTs, endTs: Int; let sampleCount: Int } + private struct Window: Codable { let id, deviceId: String; let from: Int64; var to: Int64? } + private struct Record { let ts, receivedAtMs: Int64; let columns: [Int16] } + static let shared = ImuSessionFileStore() + static let sampleRate = 100, axes = 6, blockSeconds = 30 + static let segmentSeconds: Int64 = 30 * 60 + private static let payloadBytes = sampleRate * axes * 2 + private static let magic = Data("NOOPIMU2".utf8) + private let defaults = UserDefaults.standard + private let key = "imu-session-windows-v1" + private let directory: URL + private var seen: [String: Set] = [:] + private var pending: [String: [Record]] = [:] + + private init() { + let fm = FileManager.default + let base = (try? fm.url(for: .applicationSupportDirectory, in: .userDomainMask, + appropriateFor: nil, create: true)) ?? fm.temporaryDirectory + directory = base.appendingPathComponent("OpenWhoop/RawImuSessions", isDirectory: true) + try? fm.createDirectory(at: directory, withIntermediateDirectories: true) + } + + func start(id: String, deviceId: String, fromMs: Int64) { + var value = windows().filter { $0.id != id } + value.append(Window(id: id, deviceId: deviceId, from: fromMs / 1_000, to: nil)); save(value) + try? FileManager.default.createDirectory(at: sessionDirectory(id), withIntermediateDirectories: true) + } + func complete(id: String, toMs: Int64) { + flushSession(id); var value = windows() + if let index = value.firstIndex(where: { $0.id == id }) { value[index].to = toMs / 1_000; save(value) } + } + func register(id: String, deviceId: String, fromMs: Int64, toMs: Int64) { + var value = windows().filter { $0.id != id } + value.append(Window(id: id, deviceId: deviceId, from: fromMs / 1_000, to: toMs / 1_000)); save(value) + try? FileManager.default.createDirectory(at: sessionDirectory(id), withIntermediateDirectories: true) + } + func remove(id: String) { + pending.keys.filter { $0.hasPrefix("\(id)/") }.forEach { pending[$0] = nil } + seen.keys.filter { $0.hasPrefix(sessionDirectory(id).path) }.forEach { seen[$0] = nil } + save(windows().filter { $0.id != id }) + } + func prepareForRead(_ id: String) { flushSession(id) } + + func deleteFiles(_ id: String, removeItem: (URL) throws -> Void = { try FileManager.default.removeItem(at: $0) }) -> Bool { + flushSession(id); let dir = sessionDirectory(id) + guard FileManager.default.fileExists(atPath: dir.path) else { return true } + do { try removeItem(dir); return true } catch { return false } + } + + func deleteDevice(_ deviceId: String) -> Bool { + let owned = windows().filter { $0.deviceId == deviceId }.map(\.id) + guard owned.allSatisfy({ deleteFiles($0) }) else { return false } + owned.forEach { remove(id: $0) }; return true + } + + @discardableResult + func append(deviceId: String, frame: [UInt8], receivedAtMs: Int64) -> Int { + guard let ts = Whoop5RawImu.baseTs(frame), let columns = Whoop5RawImu.rawColumns(frame) else { return 0 } + var count = 0 + for window in windows() where window.deviceId == deviceId && Int64(ts) >= window.from + && (window.to == nil || Int64(ts) <= window.to!) { + let bucket = Self.bucketStart(Int64(ts)), url = segmentFile(window.id, bucket) + var timestamps = seen[url.path] ?? scan(url) + guard timestamps.insert(Int64(ts)).inserted else { continue } + seen[url.path] = timestamps + let pendingKey = "\(window.id)/\(bucket)" + pending[pendingKey, default: []].append(Record(ts: Int64(ts), receivedAtMs: receivedAtMs, columns: columns)) + if pending[pendingKey]!.count >= Self.blockSeconds { flushKey(pendingKey) } + count += 1 + } + return count + } + + func stats(_ id: String, from: Int, to: Int) -> Stats { + let records = readRecords(id, from: from, to: to, includePending: true) + let disk = segmentFiles(id).reduce(Int64(0)) { value, url in + value + Int64((try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0) + } + let queued = pending.filter { $0.key.hasPrefix("\(id)/") }.values.flatMap { $0 } + .reduce(Int64(0)) { $0 + Int64($1.columns.count * 2 + 20) } + return Stats(bytes: disk + queued, coveredSeconds: Set(records.map(\.ts)).count, + firstTs: records.map(\.ts).min()) + } + + func exportSegments(_ id: String, from: Int, to: Int) -> [ExportSegment] { + flushSession(id) + let records = Dictionary(grouping: readRecords(id, from: from, to: to, includePending: false), by: \.ts) + .compactMap { $0.value.first }.sorted { $0.ts < $1.ts } + return Dictionary(grouping: records) { Self.bucketStart($0.ts) }.sorted { $0.key < $1.key }.compactMap { element in + let (bucket, rows) = element + guard let first = rows.first, let last = rows.last else { return nil } + return ExportSegment(name: "imu-\(Self.utcName(bucket)).imus", data: encode(bucket: bucket, records: rows), + startTs: Int(first.ts), endTs: Int(last.ts), sampleCount: rows.count * Self.sampleRate) + } + } + + private func readRecords(_ id: String, from: Int, to: Int, includePending: Bool) -> [Record] { + var rows = segmentFiles(id).flatMap { decode((try? Data(contentsOf: $0)) ?? Data()) } + .filter { $0.ts >= from && $0.ts <= to } + if includePending { rows += pending.filter { $0.key.hasPrefix("\(id)/") }.values.flatMap { $0 } + .filter { $0.ts >= from && $0.ts <= to } } + return rows + } + + private func encode(bucket: Int64, records: [Record]) -> Data { + var result = header(bucket) + for start in stride(from: 0, to: records.count, by: Self.blockSeconds) { + result.append(block(Array(records[start.. [Record] { + guard data.count >= 24, data.prefix(8) == Self.magic else { return [] } + let bytes = [UInt8](data); var offset = 8 + _ = Int64(bigEndianBytes: bytes, at: offset); offset += 8 + guard int32(bytes, offset) == Self.sampleRate, int32(bytes, offset + 4) == Self.axes else { return [] } + offset += 8; var result: [Record] = [] + while offset + 12 <= bytes.count { + let count = int32(bytes, offset), rawSize = int32(bytes, offset + 4), compressedSize = int32(bytes, offset + 8) + offset += 12 + guard count > 0, count <= Self.blockSeconds, rawSize > 0, compressedSize > 0, + offset + compressedSize <= bytes.count, + let raw = inflate(Data(bytes[offset.. Data { + var data = Self.magic; data.appendBigEndian(bucket); data.appendBigEndian(Int32(Self.sampleRate)); data.appendBigEndian(Int32(Self.axes)); return data + } + private func block(_ records: [Record]) -> Data { + var raw = Data() + for record in records { + raw.appendBigEndian(record.ts); raw.appendBigEndian(record.receivedAtMs); raw.appendBigEndian(Int32(Self.payloadBytes)) + for value in record.columns { raw.append(UInt8(truncatingIfNeeded: value)); raw.append(UInt8(truncatingIfNeeded: value >> 8)) } + } + guard let compressed = deflate(raw) else { return Data() } + var data = Data(); data.appendBigEndian(Int32(records.count)); data.appendBigEndian(Int32(raw.count)) + data.appendBigEndian(Int32(compressed.count)); data.append(compressed); return data + } + + private func windows() -> [Window] { defaults.data(forKey: key).flatMap { try? JSONDecoder().decode([Window].self, from: $0) } ?? [] } + private func save(_ value: [Window]) { defaults.set(try? JSONEncoder().encode(value), forKey: key) } + private func sessionDirectory(_ id: String) -> URL { directory.appendingPathComponent(id, isDirectory: true) } + private func segmentFile(_ id: String, _ bucket: Int64) -> URL { sessionDirectory(id).appendingPathComponent("imu-\(Self.utcName(bucket)).imus") } + private func segmentFiles(_ id: String) -> [URL] { ((try? FileManager.default.contentsOfDirectory(at: sessionDirectory(id), includingPropertiesForKeys: nil)) ?? []).filter { $0.pathExtension == "imus" }.sorted { $0.lastPathComponent < $1.lastPathComponent } } + private func scan(_ url: URL) -> Set { Set(decode((try? Data(contentsOf: url)) ?? Data()).map(\.ts)) } + private static func bucketStart(_ ts: Int64) -> Int64 { ts >= 0 ? ts / segmentSeconds * segmentSeconds : ((ts - segmentSeconds + 1) / segmentSeconds) * segmentSeconds } + private static func utcName(_ ts: Int64) -> String { let f = DateFormatter(); f.locale = Locale(identifier: "en_US_POSIX"); f.timeZone = TimeZone(secondsFromGMT: 0); f.dateFormat = "yyyyMMdd'T'HHmmss'Z'"; return f.string(from: Date(timeIntervalSince1970: TimeInterval(ts))) } + private func int32(_ bytes: [UInt8], _ offset: Int) -> Int { Int(bytes[offset]) << 24 | Int(bytes[offset + 1]) << 16 | Int(bytes[offset + 2]) << 8 | Int(bytes[offset + 3]) } + private func deflate(_ input: Data) -> Data? { let capacity = input.count + 128; var output = Data(count: capacity); let written = output.withUnsafeMutableBytes { dst in input.withUnsafeBytes { src in compression_encode_buffer(dst.bindMemory(to: UInt8.self).baseAddress!, capacity, src.bindMemory(to: UInt8.self).baseAddress!, input.count, nil, COMPRESSION_ZLIB) } }; guard written > 0 else { return nil }; output.count = written; return output } + private func inflate(_ input: Data, size: Int) -> Data? { var output = Data(count: size); let written = output.withUnsafeMutableBytes { dst in input.withUnsafeBytes { src in compression_decode_buffer(dst.bindMemory(to: UInt8.self).baseAddress!, size, src.bindMemory(to: UInt8.self).baseAddress!, input.count, nil, COMPRESSION_ZLIB) } }; return written == size ? output : nil } +} + +private extension Int64 { init(bigEndianBytes bytes: [UInt8], at offset: Int) { self = bytes[offset..<(offset + 8)].reduce(0) { ($0 << 8) | Int64($1) } } } +private extension Data { mutating func appendBigEndian(_ value: T) { var value = value.bigEndian; Swift.withUnsafeBytes(of: &value) { append(contentsOf: $0) } } } diff --git a/Strand/Collect/RawCaptureWindow.swift b/Strand/Collect/RawCaptureWindow.swift index 336273ff4a..833868f325 100644 --- a/Strand/Collect/RawCaptureWindow.swift +++ b/Strand/Collect/RawCaptureWindow.swift @@ -5,7 +5,9 @@ import Foundation /// auto-expires the window so a missed stop callback can't leak raw forever. struct RawCaptureWindow { static let minSeconds: TimeInterval = 1 - static let maxSeconds: TimeInterval = 300 + // A manually stopped research session may legitimately last for hours. It is still bounded so an + // interrupted UI/process cannot accidentally turn the opt-in capture into permanent 24/7 logging. + static let maxSeconds: TimeInterval = 24 * 60 * 60 static func clamp(_ s: TimeInterval) -> TimeInterval { min(max(s, minSeconds), maxSeconds) } private var deadline: TimeInterval? // monotonic deadline; nil = inactive diff --git a/Strand/Collect/RawDataSessionStore.swift b/Strand/Collect/RawDataSessionStore.swift new file mode 100644 index 0000000000..5e962858d3 --- /dev/null +++ b/Strand/Collect/RawDataSessionStore.swift @@ -0,0 +1,237 @@ +import Combine +import Foundation +import WhoopStore + +/// Persistent, user-controlled 5/MG capture sessions. This is the iOS/macOS twin of Android's +/// GroundTruthCollector: it records bounded raw-data windows with optional comments. +@MainActor +final class RawDataSessionStore: ObservableObject { + struct Event: Codable, Identifiable, Equatable { + var id = UUID() + var atMs: Int64 + let kind: String + var markerId: String? + var markerType: String? + var text: String? + } + + struct Session: Codable, Identifiable, Equatable { + let id: String + let deviceId: String + let startedAtMs: Int64 + var endedAtMs: Int64? + var capturedStartedAtMs: Int64? + var capturedEndedAtMs: Int64? + var comment: String + var exported: Bool + var lastExportedAtMs: Int64? + var events: [Event] + + var active: Bool { endedAtMs == nil } + } + + @Published private(set) var sessions: [Session] = [] + var active: Session? { sessions.first(where: \.active) } + + private let directory: URL + private let encoder: JSONEncoder + private let decoder = JSONDecoder() + + init(directory override: URL? = nil, fileManager: FileManager = .default) { + let base = (try? fileManager.url(for: .applicationSupportDirectory, in: .userDomainMask, + appropriateFor: nil, create: true)) ?? fileManager.temporaryDirectory + directory = override ?? base.appendingPathComponent("OpenWhoop/RawDataSessions", isDirectory: true) + try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + reload() + } + + func reload() { + let urls = (try? FileManager.default.contentsOfDirectory(at: directory, + includingPropertiesForKeys: nil)) ?? [] + sessions = urls.filter { $0.pathExtension == "json" } + .compactMap { try? Data(contentsOf: $0) } + .compactMap { try? decoder.decode(Session.self, from: $0) } + .sorted { $0.startedAtMs > $1.startedAtMs } + } + + @discardableResult + func start(deviceId: String, now: Date = Date()) -> Session? { + guard active == nil else { return nil } + let millis = Int64(now.timeIntervalSince1970 * 1_000) + let started = Event(atMs: millis, kind: "start") + let session = Session(id: String(millis), deviceId: deviceId, startedAtMs: millis, + endedAtMs: nil, capturedStartedAtMs: millis, capturedEndedAtMs: nil, + comment: "", exported: false, lastExportedAtMs: nil, events: [started]) + sessions.insert(session, at: 0) + persist(session) + ImuSessionFileStore.shared.start(id: session.id, deviceId: deviceId, fromMs: millis) + return session + } + + func stop(now: Date = Date()) { + let activeId = active?.id + mutateActive { session in + let millis = Int64(now.timeIntervalSince1970 * 1_000) + session.endedAtMs = millis + session.capturedEndedAtMs = millis + session.events.append(Event(atMs: millis, kind: "stop")) + } + if let activeId { ImuSessionFileStore.shared.complete(id: activeId, toMs: Int64(now.timeIntervalSince1970 * 1_000)) } + } + + @discardableResult + func createHistorical(deviceId: String, from: Date, to: Date) -> Session? { + let fromMs = Int64(from.timeIntervalSince1970 * 1_000) + let toMs = Int64(to.timeIntervalSince1970 * 1_000) + guard validRange(fromMs, toMs) else { return nil } + let id = String(Int64(Date().timeIntervalSince1970 * 1_000)) + let events = [Event(atMs: fromMs, kind: "start"), Event(atMs: toMs, kind: "stop")] + let session = Session(id: id, deviceId: deviceId, startedAtMs: fromMs, endedAtMs: toMs, + capturedStartedAtMs: nil, capturedEndedAtMs: nil, comment: "", + exported: false, lastExportedAtMs: nil, events: events) + sessions.insert(session, at: 0); persist(session) + ImuSessionFileStore.shared.register(id: id, deviceId: deviceId, fromMs: fromMs, toMs: toMs) + return session + } + + func setRange(sessionId: String, from: Date, to: Date) { + let fromMs = Int64(from.timeIntervalSince1970 * 1_000) + let toMs = Int64(to.timeIntervalSince1970 * 1_000) + guard validRange(fromMs, toMs) else { return } + mutate(sessionId) { session in + guard !session.active else { return } + session = Session(id: session.id, deviceId: session.deviceId, startedAtMs: fromMs, + endedAtMs: toMs, capturedStartedAtMs: session.capturedStartedAtMs, + capturedEndedAtMs: session.capturedEndedAtMs, comment: session.comment, + exported: false, lastExportedAtMs: nil, events: session.events) + } + if let session = sessions.first(where: { $0.id == sessionId }) { + ImuSessionFileStore.shared.register(id: sessionId, deviceId: session.deviceId, fromMs: fromMs, toMs: toMs) + } + } + + func setComment(_ comment: String, sessionId: String) { + mutate(sessionId) { $0.comment = String(comment.prefix(4_000)) } + } + + @discardableResult + func addMarker(sessionId: String, at: Date, type: String, text: String) -> Event? { + guard Self.markerTypes.contains(type) else { return nil } + var added: Event? + mutate(sessionId) { session in + let atMs = Self.clamp(Int64(at.timeIntervalSince1970 * 1_000), to: session) + let marker = Event(atMs: atMs, kind: "marker", markerId: UUID().uuidString, + markerType: type, text: String(text.prefix(500))) + session.events.append(marker) + added = marker + } + return added + } + + func updateMarker(sessionId: String, markerId: String, at: Date, type: String, text: String) { + guard Self.markerTypes.contains(type) else { return } + mutate(sessionId) { session in + guard let index = session.events.firstIndex(where: { $0.markerId == markerId }) else { return } + session.events[index].atMs = Self.clamp(Int64(at.timeIntervalSince1970 * 1_000), to: session) + session.events[index].markerType = type + session.events[index].text = String(text.prefix(500)) + } + } + + func deleteMarker(sessionId: String, markerId: String) { + mutate(sessionId) { $0.events.removeAll { $0.kind == "marker" && $0.markerId == markerId } } + } + + func markExported(_ sessionId: String, now: Date = Date()) { + mutate(sessionId) { + $0.exported = true + $0.lastExportedAtMs = Int64(now.timeIntervalSince1970 * 1_000) + } + } + + @discardableResult + func removeMetadata(_ sessionId: String, + removeItem: (URL) throws -> Void = { try FileManager.default.removeItem(at: $0) }) -> Bool { + guard sessions.first(where: { $0.id == sessionId })?.active == false else { return false } + guard ImuSessionFileStore.shared.deleteFiles(sessionId, removeItem: removeItem) else { return false } + ImuSessionFileStore.shared.remove(id: sessionId) + do { + let metadata = file(sessionId) + if FileManager.default.fileExists(atPath: metadata.path) { try removeItem(metadata) } + } catch { return false } + sessions.removeAll { $0.id == sessionId } + return true + } + + private func mutateActive(_ body: (inout Session) -> Void) { + guard let index = sessions.firstIndex(where: \.active) else { return } + body(&sessions[index]) + persist(sessions[index]) + } + + private func mutate(_ id: String, _ body: (inout Session) -> Void) { + guard let index = sessions.firstIndex(where: { $0.id == id }) else { return } + body(&sessions[index]) + persist(sessions[index]) + } + + private func persist(_ session: Session) { + guard let data = try? encoder.encode(session) else { return } + try? data.write(to: file(session.id), options: .atomic) + } + + private func file(_ id: String) -> URL { directory.appendingPathComponent("session-\(id).json") } + + private func validRange(_ from: Int64, _ to: Int64) -> Bool { + from > 0 && from <= to && to - from <= 7 * 24 * 60 * 60 * 1_000 + } + + static let markerTypes = ["moment", "start", "end", "issue"] + + private static func clamp(_ atMs: Int64, to session: Session) -> Int64 { + min(max(atMs, session.startedAtMs), session.endedAtMs ?? Int64(Date().timeIntervalSince1970 * 1_000)) + } + + func exportEntries(for session: Session, sensorAvailable: Bool = false) -> [FileExport.BundleEntry] { + var meta: [String: Any] = [ + "schema_version": 3, "capture_kind": "whoop_5mg_raw_data", + "session_id": session.id, "device_id": session.deviceId, + "started_at_ms": session.startedAtMs, "ended_at_ms": session.endedAtMs ?? session.startedAtMs, + "comment": session.comment, + "sensor_export_available": sensorAvailable, + "device_family": "iOS", + "app_version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?", + "markers": session.events.filter { + $0.kind == "marker" && $0.atMs >= session.startedAtMs + && $0.atMs <= (session.endedAtMs ?? session.startedAtMs) + }.map { marker in + ["id": marker.markerId ?? "", "at_ms": marker.atMs, + "type": marker.markerType ?? "moment", "text": marker.text ?? ""] as [String: Any] + }, + ] + if let value = session.capturedStartedAtMs { meta["captured_started_at_ms"] = value } + if let value = session.capturedEndedAtMs { meta["captured_ended_at_ms"] = value } + let metaData = (try? JSONSerialization.data(withJSONObject: meta, options: [.prettyPrinted, .sortedKeys])) ?? Data() + let endMs = session.endedAtMs ?? session.startedAtMs + let publicEvents = [Event(atMs: session.startedAtMs, kind: "start")] + + session.events.filter { + $0.kind == "marker" && $0.atMs >= session.startedAtMs && $0.atMs <= endMs + }.sorted { $0.atMs < $1.atMs } + + [Event(atMs: endMs, kind: "stop")] + let eventData = publicEvents.compactMap { event -> Data? in + var row: [String: Any] = ["at_ms": event.atMs, "kind": event.kind] + if event.kind == "marker" { + if let value = event.markerId { row["marker_id"] = value } + if let value = event.markerType { row["marker_type"] = value } + if let value = event.text { row["text"] = value } + } + return try? JSONSerialization.data(withJSONObject: row, options: [.sortedKeys]) + }.reduce(into: Data()) { $0.append($1); $0.append(0x0A) } + return [ + .init(name: "meta.json", data: metaData), + .init(name: "events.jsonl", data: eventData), + ] + } +} diff --git a/Strand/Data/BackupSync.swift b/Strand/Data/BackupSync.swift index b485d7caee..c04b8629bc 100644 --- a/Strand/Data/BackupSync.swift +++ b/Strand/Data/BackupSync.swift @@ -202,11 +202,53 @@ enum FolderBackup { return dir } + /// Whether a folder path sits in iCloud. The SINGLE definition of that rule — `restoreListHealth` + /// reports the same signal to diagnostics, and a screen that disagreed with the diagnostics dump + /// about where the backups are would make #278-style triage worse, not better. + /// + /// Ubiquity containers live under `Mobile Documents`, which covers both iCloud Drive proper + /// (`com~apple~CloudDocs`) and per-app containers. Pure and path-only, so it is testable without a + /// picker, a bookmark or a device. + static func isICloudPath(_ path: String) -> Bool { path.contains("Mobile Documents") } + + /// The tail of a folder path, for display: `"NOOP › 2026"`. + /// + /// The bare last component is not enough to identify a folder — a "Backups" folder in iCloud Drive + /// and the #52 internal fallback's `Documents/Backups` render identically, which is exactly the + /// confusion this line is meant to settle. The FULL path is not shown either: on iOS it is a long + /// sandbox UUID and on macOS it carries the user's home directory, neither of which helps. + /// + /// For an iCloud path everything up to and including the sync root (`com~apple~CloudDocs`, or a + /// per-app `iCloud~…` container) is dropped, since [folderLabel] already names iCloud Drive and the + /// raw tilde form means nothing to a reader. + static func folderTrail(path: String, maxComponents: Int = 2) -> String { + var parts = path.split(separator: "/").map(String.init).filter { !$0.isEmpty } + // Slice AFTER the sync root rather than filtering it out: on macOS an iCloud path is + // `~/Library/Mobile Documents/com~apple~CloudDocs/NOOP`, so merely dropping the tilde component + // leaves "Mobile Documents › NOOP" — plumbing the reader should never see. + if let i = parts.lastIndex(where: { + $0 == "com~apple~CloudDocs" || $0.hasPrefix("iCloud~") || $0 == "Mobile Documents" + }) { parts = Array(parts[(i + 1)...]) } + return parts.suffix(max(1, maxComponents)).joined(separator: " › ") + } + /// A short, human label for the chosen folder, or nil if none chosen. The internal fallback gets a /// friendly name instead of the raw "Backups" path component. + /// + /// Names iCloud explicitly (#52): the picker can already point anywhere, iCloud Drive included, so + /// the gap was never the choosing — it was that the result was unidentifiable afterwards. A user on + /// the internal fallback saw a bare "Backups" and could not tell their backups were on-device only. static func folderLabel() -> String? { if useInternalFolder { return String(localized: "NOOP (in Files)") } - return resolveFolder()?.lastPathComponent + guard let path = resolveFolder()?.path else { return nil } + let trail = folderTrail(path: path) + if isICloudPath(path) { + // Picking the iCloud Drive root itself leaves no trail; naming the place alone beats an + // empty separator dangling off the end. + return trail.isEmpty ? String(localized: "iCloud Drive") + : String(localized: "iCloud Drive › \(trail)") + } + return trail.isEmpty ? (path.split(separator: "/").last.map(String.init) ?? path) : trail } // MARK: - Security-scoped bookmark @@ -265,7 +307,13 @@ enum FolderBackup { let nowMs = Int(Date().timeIntervalSince1970 * 1000.0) let dest = folder.appendingPathComponent(BackupSync.snapshotName(nowMs)) - guard case .exported = await DataBackup.writeBackup(checkpoint: checkpoint, to: dest) else { return false } + // #1807: an oversize write still WROTE the file, so it is a success here. The folder sync has + // nowhere to surface a warning and must not report failure for a backup that exists — the + // interactive export is where the user is told, because that is where they can act on it. + switch await DataBackup.writeBackup(checkpoint: checkpoint, to: dest) { + case .exported, .exportedOversize: break + default: return false + } UserDefaults.standard.set(nowMs, forKey: lastKey) prune(in: folder) @@ -304,8 +352,9 @@ enum FolderBackup { let raw = (try? FileManager.default.contentsOfDirectory(atPath: folder.path))?.count ?? 0 // Report iCloud-vs-local (the actual #278 triage signal — undownloaded placeholders live in the // iCloud container) WITHOUT the folder's user-chosen name, which redactPii can't scrub and the - // iOS twin never exposes. The iCloud ubiquity container path contains "Mobile Documents". - let isICloud = folder.path.contains("Mobile Documents") + // iOS twin never exposes. Shares `isICloudPath` with the label the user sees, so the screen and + // this dump can never disagree about where the backups are. + let isICloud = isICloudPath(folder.path) return (isICloud, raw, listSnapshots().count) } diff --git a/Strand/Data/DataBackup.swift b/Strand/Data/DataBackup.swift index a37bfada2c..8b56e96aa4 100644 --- a/Strand/Data/DataBackup.swift +++ b/Strand/Data/DataBackup.swift @@ -27,7 +27,12 @@ import ZIPFoundation /// security-scoped access on the panel-returned URLs. Every path is best-effort — failures surface /// as a `.failure` result and never crash. enum DataBackup { - private static let maxBackupSQLiteBytes: Int64 = 2_147_483_648 + /// Uncompressed ceiling for the SQLite entry, enforced while EXTRACTING (see `extractBackupZip`). + /// Deliberately measured on the decompressed stream: this is a zip-bomb guard, and a zip bomb is by + /// definition small compressed and enormous expanded, so a cap on the archive's own size would be + /// trivially defeated. Compressing harder cannot help a user past it — the backup is already + /// `.deflate`d and the count is of bytes landing on disk. (#1807) + static let maxBackupSQLiteBytes: Int64 = 2_147_483_648 private static let maxBackupSettingsBytes: Int64 = 1_048_576 // MARK: - Result @@ -35,11 +40,21 @@ enum DataBackup { enum BackupResult { /// Export wrote the backup to `url`. case exported(URL) + /// Export wrote the backup, but the database is larger than [maxBackupSQLiteBytes], the ceiling + /// the restore path enforces. The file is valid and worth keeping — restoring it just needs the + /// user to confirm once. Reported at EXPORT time because the alternative is finding out during a + /// restore, which is exactly when the original is gone. (#1807) + case exportedOversize(URL, bytes: Int64, limit: Int64) /// Import succeeded; a relaunch is required for it to take effect. `sidecar` is where the /// previous database was preserved, in case the user wants to roll back. case imported(sidecar: URL) /// The user dismissed the save/open panel — nothing happened, show nothing loud. case cancelled + /// The restore stopped ONLY because an entry exceeds the size ceiling. Distinct from `failure` + /// so the caller can offer to go ahead anyway: the cap is a decompression guard against a hostile + /// archive, and a backup the user just picked out of their own files is a different threat model + /// than the one it defends against. (#1807) + case restoreTooLarge(name: String, limit: Int64) /// Something went wrong; `message` is user-facing. case failure(String) } @@ -94,7 +109,7 @@ enum DataBackup { try await Task.detached(priority: .utility) { try writeVerifiedBackupZip(dbURL: dbURL, to: dest, settingsJSON: currentSettingsJSON()) }.value - return .exported(dest) + return exportOutcome(dest, dbURL: dbURL) } catch { return .failure(String(localized: "Export failed: \(error.localizedDescription)")) } @@ -113,7 +128,7 @@ enum DataBackup { return .failure(String(localized: "Export failed: \(error.localizedDescription)")) } guard let dest = await DocumentPicker.export(staged) else { return .cancelled } - return .exported(dest) + return exportOutcome(dest, dbURL: dbURL) #endif } @@ -144,6 +159,17 @@ enum DataBackup { /// when the original data may be long gone; failing loudly NOW is the honest move. The read-only /// probe sits safely beside the app's open GRDB pool (WAL allows concurrent readers). /// `writeBackupForTesting` deliberately bypasses this so tests can build damaged containers. + /// `.exported`, or `.exportedOversize` when the database is past the ceiling the restore path + /// enforces. Measured on the DATABASE, not the finished archive: the archive is `.deflate`d and the + /// cap the restore checks counts the DECOMPRESSED stream, so the zip's own size says nothing about + /// whether it can be read back. (#1807) + private static func exportOutcome(_ dest: URL, dbURL: URL) -> BackupResult { + let attrs = try? FileManager.default.attributesOfItem(atPath: dbURL.path) + guard let bytes = (attrs?[.size] as? NSNumber)?.int64Value, + bytes > maxBackupSQLiteBytes else { return .exported(dest) } + return .exportedOversize(dest, bytes: bytes, limit: maxBackupSQLiteBytes) + } + private static func writeVerifiedBackupZip(dbURL: URL, to dest: URL, settingsJSON: Data?) throws { if let complaint = DatabaseIntegrity.quickCheckFailure(atPath: dbURL.path) { throw ExportIntegrityFailure(complaint: complaint) @@ -246,7 +272,7 @@ enum DataBackup { let fm = FileManager.default if fm.fileExists(atPath: dest.path) { try fm.removeItem(at: dest) } try writeVerifiedBackupZip(dbURL: dbURL, to: dest, settingsJSON: currentSettingsJSON()) - return .exported(dest) + return exportOutcome(dest, dbURL: dbURL) } catch { return .failure(String(localized: "Backup failed: \(error.localizedDescription)")) } @@ -273,7 +299,7 @@ enum DataBackup { /// siblings). The store stays open, so the swapped-in file only takes effect after a relaunch — /// the caller informs the user. @MainActor - static func runImport() async -> BackupResult { + static func runImport(allowOversize: Bool = false) async -> BackupResult { let dbPath: String do { dbPath = try StorePaths.defaultDatabasePath() } catch { return .failure(String(localized: "Couldn't locate the NOOP database. \(error.localizedDescription)")) } @@ -305,7 +331,7 @@ enum DataBackup { // stays valid because the surrounding function is still awaiting here. Only Sendable value // types (URL, String) cross the hop; the result hops back to main for handleBackup. return await Task.detached(priority: .utility) { - restore(from: pickedSource, toDatabaseAt: dbPath) + restore(from: pickedSource, toDatabaseAt: dbPath, allowOversize: allowOversize) }.value } @@ -330,7 +356,8 @@ enum DataBackup { /// `settingsDefaults` is where a `settings.json` entry (#1000) is re-applied — injected for the /// same reason as `dbPath` (tests use a suite-scoped UserDefaults, never the runner's real domain). static func restore(from pickedSource: URL, toDatabaseAt dbPath: String, - settingsDefaults: UserDefaults = .standard) -> BackupResult { + settingsDefaults: UserDefaults = .standard, + allowOversize: Bool = false) -> BackupResult { // If the picked file is a .noopbak ZIP, extract the SQLite entry to a temp dir first. // Legacy plain-SQLite files fall straight through. The extracted dir is cleaned up below. let fm = FileManager.default @@ -343,7 +370,15 @@ enum DataBackup { do { if fm.fileExists(atPath: tmpExtract.path) { try fm.removeItem(at: tmpExtract) } try fm.createDirectory(at: tmpExtract, withIntermediateDirectories: true) - try extractBackupZip(at: pickedSource, into: tmpExtract) + try extractBackupZip(at: pickedSource, into: tmpExtract, allowOversize: allowOversize) + } catch let err as BackupArchiveError { + try? fm.removeItem(at: tmpExtract) + // Reported as its own case, not a generic failure: this one is RECOVERABLE, and the caller + // is the only layer that can ask the user whether to go ahead. (#1807) + switch err { + case .entryTooLarge(let name): + return .restoreTooLarge(name: name, limit: maxBackupSQLiteBytes) + } } catch { try? fm.removeItem(at: tmpExtract) return .failure(String(localized: "Couldn't open the backup archive: \(error.localizedDescription)")) @@ -591,14 +626,15 @@ enum DataBackup { /// Extract only the canonical entries from a `.noopbak` ZIP at `zipURL` into `destDir`. /// Unknown files are ignored, and each accepted entry is streamed through an uncompressed-size cap /// before it lands on disk. - private static func extractBackupZip(at zipURL: URL, into destDir: URL) throws { + private static func extractBackupZip(at zipURL: URL, into destDir: URL, + allowOversize: Bool = false) throws { let archive = try Archive(url: zipURL, accessMode: .read) for entry in archive where entry.type == .file { let name = (entry.path as NSString).lastPathComponent let limit: Int64 switch name { case backupEntryName: - limit = maxBackupSQLiteBytes + limit = allowOversize ? Int64.max : maxBackupSQLiteBytes case BackupSettings.entryName: limit = maxBackupSettingsBytes default: diff --git a/Strand/Data/DayCycleIntelligenceIntegration.swift b/Strand/Data/DayCycleIntelligenceIntegration.swift new file mode 100644 index 0000000000..386e805c45 --- /dev/null +++ b/Strand/Data/DayCycleIntelligenceIntegration.swift @@ -0,0 +1,335 @@ +import Foundation +import StrandAnalytics +import WhoopProtocol +import WhoopStore + +@MainActor enum DayCycleIntelligenceIntegration { + static let onsetKey = "day_cycle_onset_ts" + static let pageSize = 10_000 + + struct Night { let daily: DailyMetric; let sleeps: [CachedSleepSession]; let workouts: [ExerciseSession]; let owner: String } + struct SourcedMarker: Sendable { let deviceId: String; let point: MetricPoint } + struct BoundaryRecoveryReader { + let sleepSessions: (String, Int, Int) async throws -> [CachedSleepSession] + let markers: (String, String, String) async throws -> [MetricPoint] + } + enum MarkerUpdate { + case preserve + case replace(points: [SourcedMarker], sourceIds: [String]) + } + struct Result { + let stepsByWakeDay: [String: Int] + let strainByWakeDay: [String: Double] + let caloriesByWakeDay: [String: Double] + let workoutCountByWakeDay: [String: Int] + let onsetByWakeDay: [String: Int] + let firstWakeDay: String? + let markerUpdate: MarkerUpdate + } + struct PersistedBoundary { + let boundary: PhysiologicalSteps.CycleBoundary + let wakeDay: String + let owner: String + let sleepContext: SleepSession + } + fileprivate struct CachedCycle { + let key: String; let count: SleepAwareStepCounter.Count + let pages: Int; let samples: Int; let evaluated: Bool + } + final class Cache { + fileprivate var cycles: [String: CachedCycle] = [:] + } + private static func computedId(_ owner: String) -> String { owner + "-noop" } + + static func recover(candidates: [(owner: String, priority: Int)], reader: BoundaryRecoveryReader, + claimedDays: Set, windowStart: Int, now: Int, + offsetSec: Int, habitualMidsleepSec: Int?) async throws -> [PersistedBoundary] { + var claimed = claimedDays, output: [PersistedBoundary] = [] + let fromDay = AnalyticsEngine.dayString(windowStart, offsetSec: offsetSec) + let toDay = AnalyticsEngine.dayString(now, offsetSec: offsetSec) + for candidate in candidates.sorted(by: { + $0.priority == $1.priority ? $0.owner < $1.owner : $0.priority < $1.priority + }) { + let source = computedId(candidate.owner) + let sessions = try await reader.sleepSessions(source, windowStart, now) + let points = try await reader.markers(source, fromDay, toDay) + let markers = Dictionary(points.compactMap { point -> (String, Int)? in + let value = Int(point.value) + return point.value == Double(value) ? (point.day, value) : nil + }, uniquingKeysWith: { first, _ in first }) + let grouped = Dictionary(grouping: sessions) { + AnalyticsEngine.dayString($0.endTs, offsetSec: offsetSec) + } + for day in grouped.keys.sorted() where !claimed.contains(day) { + guard let marker = markers[day] else { continue } + let rows = grouped[day] ?? [] + let blocks = rows.map { PhysiologicalSteps.SleepBlock( + onset: $0.startTs, end: $0.endTs, id: String($0.startTs), + editedOnset: $0.startTsAdjusted) } + guard let winner = PhysiologicalSteps.classifyForCycle( + blocks, offsetSec: offsetSec, habitualMidsleepSec: habitualMidsleepSec) + .first(where: { $0.kind == .mainSleep && $0.effectiveOnset == marker }), + let row = rows.first(where: { + String($0.startTs) == winner.id && $0.effectiveStartTs == marker + }) else { continue } + let context = SleepSession(start: row.effectiveStartTs, end: row.endTs, + efficiency: row.efficiency ?? 0, stages: AnalyticsEngine.decodeStages(row.stagesJSON), + restingHR: row.restingHr, avgHRV: row.avgHrv) + output.append(PersistedBoundary(boundary: .init( + sleepId: "persisted:\(candidate.owner):\(row.startTs)", onset: marker), + wakeDay: day, owner: candidate.owner, sleepContext: context)) + claimed.insert(day) + } + } + return output + } + + static func compute(nights: [Night], editedRows: [CachedSleepSession], store: WhoopStore, + candidates: [(owner: String, priority: Int)], physiologyOwners: [String], + workouts: [WorkoutRow], windowStart: Int, + now: Int, offsetSec: Int, habitualMidsleepSec: Int?, ticksPerStep: Double, + mode: DayCycleMode, cache: Cache, + profile: UserProfile, maxHROverride: Double?, effortMethod: StrainScorer.Method, + recoveryReader: BoundaryRecoveryReader? = nil, + trace: ((String) -> Void)? = nil) async -> Result { + guard mode == .sleepOnset else { + return Result(stepsByWakeDay: [:], strainByWakeDay: [:], caloriesByWakeDay: [:], + workoutCountByWakeDay: [:], onsetByWakeDay: [:], firstWakeDay: nil, + markerUpdate: .replace(points: [], sourceIds: Array(Set( + candidates.map { computedId($0.owner) })).sorted())) + } + let editsByDay = Dictionary(grouping: editedRows) { + AnalyticsEngine.dayString($0.endTs, offsetSec: offsetSec) + } + var boundaries: [PhysiologicalSteps.CycleBoundary] = [] + var wakeDayById: [String: String] = [:], ownerById: [String: String] = [:] + var sleepContexts: [SleepSession] = [] + for night in nights { + let dayEdits = editsByDay[night.daily.day] ?? [] + let edits = Dictionary(dayEdits.map { ($0.startTs, $0) }, uniquingKeysWith: { first, _ in first }) + let detectedStarts = Set(night.sleeps.map(\.startTs)) + var blocks: [PhysiologicalSteps.SleepBlock] = [] + for row in night.sleeps { + let edit = edits[row.startTs] + let start = edit?.effectiveStartTs ?? row.effectiveStartTs + let end = edit?.endTs ?? row.endTs + blocks.append(.init(onset: row.startTs, end: end, id: String(row.startTs), editedOnset: start)) + sleepContexts.append(SleepSession(start: start, end: end, + efficiency: row.efficiency ?? 0, stages: AnalyticsEngine.decodeStages(row.stagesJSON), + restingHR: row.restingHr, avgHRV: row.avgHrv)) + } + for edit in dayEdits where !detectedStarts.contains(edit.startTs) { + blocks.append(.init(onset: edit.startTs, end: edit.endTs, + id: "manual:\(edit.startTs)", editedOnset: edit.startTsAdjusted, kind: .nap)) + sleepContexts.append(SleepSession(start: edit.effectiveStartTs, end: edit.endTs, + efficiency: 0, stages: [], restingHR: nil, avgHRV: nil)) + } + let classified = PhysiologicalSteps.classifyForCycle( + blocks, offsetSec: offsetSec, habitualMidsleepSec: habitualMidsleepSec) + guard let winner = classified.filter({ $0.kind == .mainSleep }) + .min(by: { $0.effectiveOnset < $1.effectiveOnset }), winner.effectiveOnset <= now else { continue } + boundaries.append(.init(sleepId: winner.id, onset: winner.effectiveOnset)) + wakeDayById[winner.id] = night.daily.day; ownerById[winner.id] = night.owner + } + + let recovered: [PersistedBoundary] + do { + let productionReader = BoundaryRecoveryReader( + sleepSessions: { source, from, to in + try await store.sleepSessions(deviceId: source, from: from, to: to, limit: 4_000) + }, + markers: { source, fromDay, toDay in + try await store.metricSeries( + deviceId: source, key: onsetKey, from: fromDay, to: toDay) + }) + recovered = try await recover(candidates: candidates, + reader: recoveryReader ?? productionReader, + claimedDays: Set(wakeDayById.values), windowStart: windowStart, now: now, + offsetSec: offsetSec, habitualMidsleepSec: habitualMidsleepSec) + } catch { + // Fail closed: an unread namespace is unknown, not empty. Returning no replacement source IDs + // prevents the persistence transaction from deleting valid markers after a transient read error. + trace?("stepsCycle status=error error=boundaryRecoveryRead") + return Result(stepsByWakeDay: [:], strainByWakeDay: [:], caloriesByWakeDay: [:], + workoutCountByWakeDay: [:], onsetByWakeDay: [:], firstWakeDay: nil, + markerUpdate: .preserve) + } + for item in recovered { + boundaries.append(item.boundary); wakeDayById[item.boundary.sleepId] = item.wakeDay + ownerById[item.boundary.sleepId] = item.owner; sleepContexts.append(item.sleepContext) + } + if let latest = boundaries.max(by: { $0.onset < $1.onset }), + let day = wakeDayById[latest.sleepId], let owner = ownerById[latest.sleepId] { + let active = DayCycleResolver.activeWindow(mode: mode, + latestSleep: DayCycleWindow(id: latest.sleepId, startInclusive: latest.onset, + endExclusive: now, displayDay: day, source: .detectedSleep), now: now, + offsetSec: offsetSec) + if active.source == .syntheticMidnight { + boundaries.append(.init(sleepId: active.id, onset: active.startInclusive)) + wakeDayById[active.id] = active.displayDay; ownerById[active.id] = owner + } + } + + let windows = PhysiologicalSteps.cycleWindows(boundaries, now: now) + cache.cycles = cache.cycles.filter { entry in windows.contains(where: { $0.sleepId == entry.key }) } + let priorities = Dictionary(candidates.map { ($0.owner, $0.priority) }, uniquingKeysWith: min) + let witnesses = Dictionary(uniqueKeysWithValues: nights.map { night in + let sleeps = night.sleeps.sorted { $0.startTs < $1.startTs }.map { + "\($0.startTs)-\($0.endTs):\($0.stagesJSON ?? "")" + }.joined(separator: "|") + return (night.daily.day, "\(night.owner):\(night.daily.steps.map(String.init) ?? "nil")|\(sleeps)") + }) + var steps: [String: Int] = [:], onsets: [String: Int] = [:] + var strains: [String: Double] = [:], calories: [String: Double] = [:] + var workoutCounts: [String: Int] = [:] + windowLoop: for window in windows { + guard let day = wakeDayById[window.sleepId], let fallback = ownerById[window.sleepId] else { continue } + do { + onsets[day] = window.onset + // Store ranges are inclusive. Read the active-first WHOOP + canonical union without borrowing + // step coverage: an HR-only device may legitimately have no step rows. + let hrEndInclusive = window.endExclusive - 1 + let owners = ([fallback] + physiologyOwners).reduce(into: [String]()) { + if !$0.contains($1) { $0.append($1) } + } + var hrByTimestamp: [Int: HRSample] = [:] + if hrEndInclusive >= window.onset { + for owner in owners { + let rows = (try? await store.hrSamples( + deviceId: owner, from: window.onset, to: hrEndInclusive, limit: 200_000)) ?? [] + for row in rows where hrByTimestamp[row.ts] == nil { hrByTimestamp[row.ts] = row } + } + } + let cycleHR = hrByTimestamp.values.sorted { $0.ts < $1.ts } + let restingHR = nights.first(where: { $0.daily.day == day })?.daily.restingHr.map(Double.init) + ?? StrainScorer.defaultRestingHR + let effectiveMaxHR = maxHROverride ?? (profile.age > 0 ? StrainScorer.tanakaHRmax(age: profile.age) : nil) + if let strain = StrainScorer.strain(cycleHR, maxHR: effectiveMaxHR, + restingHR: restingHR, method: effortMethod, + sex: profile.sex) { strains[day] = strain } + if !cycleHR.isEmpty { + calories[day] = Calories.estimateDayCalories( + cycleHR, profile: profile, hrmax: effectiveMaxHR, restingHR: restingHR) + } + let persistedWorkoutKeys = workouts + .filter { $0.startTs >= window.onset && $0.startTs < window.endExclusive } + .map { "\($0.startTs):\($0.endTs)" } + let freshDetectedKeys = nights.flatMap(\.workouts) + .filter { $0.start >= window.onset && $0.start < window.endExclusive } + .filter { detected in + !workouts.contains { persisted in + detected.start < persisted.endTs && persisted.startTs < detected.end + } + } + .map { "\($0.start):\($0.end)" } + workoutCounts[day] = Set(persistedWorkoutKeys + freshDetectedKeys).count + var ranked = priorities; ranked[fallback] = ranked[fallback] ?? ranked.values.min() ?? 0 + var coverage: [PhysiologicalSteps.OwnerCoverage] = [] + for (owner, priority) in ranked { + let span = try await store.stepTimestampCoverage( + deviceId: owner, from: window.onset, to: window.endExclusive) + if let first = span.first, let last = span.last { + coverage.append(.init(owner: owner, onset: first, + endExclusive: min(last + 1, window.endExclusive), priority: priority)) + } + } + let segments = PhysiologicalSteps.ownerSegmentsFromCoverage( + window, coverage: coverage, fallbackOwner: fallback) + guard !segments.isEmpty else { continue } + let active = window.endExclusive == now + let identity = segments.enumerated().map { index, segment in + "\(segment.owner):\(segment.onset)-\(active && index == segments.count - 1 ? 0 : segment.endExclusive)" + }.joined(separator: ",") + var revisions: [String] = [] + for segment in segments { + let revision = await store.stepDataRevisionSignature( + deviceId: segment.owner, from: segment.onset, to: segment.endExclusive) + revisions.append("\(segment.owner)=\(revision)") + } + let contextSignature = sleepContexts.filter { $0.end > window.onset && $0.start < window.endExclusive } + .sorted { $0.start < $1.start }.map { sleep in + "\(sleep.start)-\(sleep.end):" + sleep.stages.sorted { $0.start < $1.start } + .map { "\($0.start)-\($0.end)=\($0.stage)" }.joined(separator: ":") + }.joined(separator: "|") + let firstDay = AnalyticsEngine.dayString(window.onset, offsetSec: offsetSec) + let lastDay = AnalyticsEngine.dayString(max(window.onset, window.endExclusive - 1), offsetSec: offsetSec) + let dayWitness = witnesses.keys.filter { $0 >= firstDay && $0 <= lastDay }.sorted() + .map { "\($0)=\(witnesses[$0] ?? "")" }.joined(separator: "|") + let key = "\(identity)|\(window.sleepId)|\(window.onset)|\(active ? 0 : window.endExclusive)" + + "|stepRevision=\(revisions.joined(separator: "|"))|sleepContext=\(contextSignature)" + + "|days=\(dayWitness)" + var cached = cache.cycles[window.sleepId] + if cached?.key != key { + var count = SleepAwareStepCounter.Count.empty, pages = 0, samples = 0, evaluated = false + for (index, segment) in segments.enumerated() { + let hasClasses = try await store.hasStepActivityClasses( + deviceId: segment.owner, from: segment.onset, to: segment.endExclusive) + let accumulator = SleepAwareStepCounter.Accumulator( + sleepSessions: sleepContexts, hasActivityClasses: hasClasses) + var segmentSamples = 0 + if index == 0, let predecessor = try await store.stepSampleBefore( + deviceId: segment.owner, before: segment.onset) { + accumulator.acceptPage([predecessor]); samples += 1; segmentSamples += 1 + } + var cursor = segment.onset - 1 + while cursor < segment.endExclusive { + let page = try await store.stepSamplesPage(deviceId: segment.owner, + afterExclusive: cursor, endExclusive: segment.endExclusive, limit: pageSize) + guard !page.isEmpty else { break } + accumulator.acceptPage(page); pages += 1; samples += page.count; segmentSamples += page.count + guard let last = page.last, last.ts >= cursor else { break } + cursor = last.ts + if page.count < pageSize { break } + } + let motion = try? await store.stepDiagnosticMotionCounts( + deviceId: segment.owner, from: segment.onset, to: segment.endExclusive) + accumulator.observeMotion(gravityCount: motion?.gravity ?? 0, auxCount: motion?.aux ?? 0) + if segmentSamples >= 2 { evaluated = true } + count = count.adding(accumulator.finish()) + } + cached = CachedCycle(key: key, count: count, pages: pages, samples: samples, evaluated: evaluated) + cache.cycles[window.sleepId] = cached + } + guard let result = cached, result.evaluated else { continue } + let scaled = Int((Double(result.count.totalTicks) / max(ticksPerStep, 0.5)).rounded()) + steps[day] = scaled + let status = active ? "active" : "closed" + trace?("stepsCycle wakeDay=\(day) status=\(status) onsetTs=\(window.onset) " + + "endTs=\(window.endExclusive) owner=\(identity) pages=\(result.pages) samples=\(result.samples) " + + "totalTicks=\(result.count.totalTicks) outside=\(result.count.acceptedOutsideSleepTicks) " + + "awakeGap=\(result.count.acceptedAwakeGapTicks) sleepBout=\(result.count.acceptedSleepBoutTicks) " + + "rejectedIsolatedSleep=\(result.count.rejectedIsolatedSleepTicks) " + + "rejectedClass=\(result.count.rejectedActivityClassTicks) " + + "rejectedImplausible=\(result.count.rejectedImplausibleTicks) " + + "gravitySamples=\(result.count.gravitySamplesAvailable) auxSamples=\(result.count.auxSamplesAvailable) " + + "ticksPerStep=\(ticksPerStep) scaledSteps=\(scaled)") + } catch { + trace?("stepsCycle wakeDay=\(day) status=error error=databaseRead") + continue windowLoop + } + } + let recoveredMarkers = recovered.map { SourcedMarker(deviceId: computedId($0.owner), + point: MetricPoint(day: $0.wakeDay, key: onsetKey, value: Double($0.boundary.onset))) } + return Result(stepsByWakeDay: steps, strainByWakeDay: strains, caloriesByWakeDay: calories, + workoutCountByWakeDay: workoutCounts, onsetByWakeDay: onsets, + firstWakeDay: wakeDayById.values.min(), markerUpdate: .replace( + points: recoveredMarkers, + sourceIds: Array(Set(candidates.map { computedId($0.owner) })).sorted())) + } + + static func applying(_ result: Result, to daily: DailyMetric) -> DailyMetric { + let established = result.firstWakeDay.map { daily.day >= $0 } ?? false + let steps = established ? result.stepsByWakeDay[daily.day] : daily.steps + let strain = established ? result.strainByWakeDay[daily.day] : daily.strain + let calories = established ? result.caloriesByWakeDay[daily.day] : daily.activeKcalEst + let workouts = established ? result.workoutCountByWakeDay[daily.day] : daily.exerciseCount + return DailyMetric(day: daily.day, totalSleepMin: daily.totalSleepMin, efficiency: daily.efficiency, + deepMin: daily.deepMin, remMin: daily.remMin, lightMin: daily.lightMin, + disturbances: daily.disturbances, restingHr: daily.restingHr, avgHrv: daily.avgHrv, + recovery: daily.recovery, strain: strain, exerciseCount: workouts, + spo2Pct: daily.spo2Pct, skinTempDevC: daily.skinTempDevC, respRateBpm: daily.respRateBpm, + steps: steps, activeKcalEst: calories, spo2Red: daily.spo2Red, + spo2Ir: daily.spo2Ir, avgSdnn: daily.avgSdnn, + skinTempC: daily.skinTempC, sleepHrOnly: daily.sleepHrOnly) + } +} diff --git a/Strand/Data/DeviceRegistry.swift b/Strand/Data/DeviceRegistry.swift index 5060fae5d7..032d70a1c3 100644 --- a/Strand/Data/DeviceRegistry.swift +++ b/Strand/Data/DeviceRegistry.swift @@ -92,6 +92,7 @@ final class DeviceRegistry: ObservableObject { /// Best-effort: a store failure leaves the recordings and published state untouched. Awaits the delete /// BEFORE `reload()` so the refreshed device list reflects the emptied recordings. func deleteDeviceData(_ id: String, store: WhoopStore) async { + guard ImuSessionFileStore.shared.deleteDevice(id) else { return } do { try await store.deleteAllData(deviceId: id) } catch { @@ -108,6 +109,7 @@ final class DeviceRegistry: ObservableObject { /// and refreshes the published list. Best-effort: if the data wipe fails the registry row is left in /// place (we never leave orphaned recordings behind a removed row). func forget(_ id: String, store: WhoopStore) async { + guard ImuSessionFileStore.shared.deleteDevice(id) else { return } do { try await store.deleteAllData(deviceId: id) } catch { diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index ecb8e01162..234f60d16c 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -21,6 +21,7 @@ final class IntelligenceEngine: ObservableObject { /// is worth the few characters. static let minHrSamples = 200 + private let dayCycleCache = DayCycleIntelligenceIntegration.Cache() private let repo: Repository private let profile: ProfileStore /// The CANONICAL id under whose `-noop` sibling this engine WRITES the computed daily rows, and from @@ -137,6 +138,9 @@ final class IntelligenceEngine: ObservableObject { private struct DayScan { let result: AnalyticsEngine.DayResult let rhrLine: String? + /// #1943 measure-only bin-population line (see `SleepStager.rhrBinGateLogLine`); nil on a clean + /// night, replayed with `rhrLine` so the two read together. + let rhrBinLine: String? /// #1331 respiratory diagnostic line (see `respRateLogLine`); replayed with `rhrLine`. let respLine: String? /// CAPTURE-B (#814/#799): the resolved READ owner id this day was scored from, and how many HR rows @@ -324,11 +328,60 @@ final class IntelligenceEngine: ObservableObject { /// Counts + a window length only — same privacy class as the sibling `sleep day=` line, no PII. Pure so /// it's unit-tested directly; byte-identical to the Android `sleepDetectNoNightLogLine`. +/// Fold the per-day diagnostic lines onto the one channel that already replays them. + /// + /// `hrvDiag` is not really "the HRV line" any more — it is the loop-1 diagnostic channel, replayed + /// on the main actor by splitting on newlines. The Effort funnel rides it for the same reason the + /// NO-NIGHT line does: built where the inputs are in scope, emitted where the sink is safe to touch. + nonisolated static func mergedDayDiag(_ hrvDiag: String?, _ extra: [String]) -> String? { + var parts: [String] = [] + if let hrvDiag, !hrvDiag.isEmpty { parts.append(hrvDiag) } + parts.append(contentsOf: extra) + return parts.isEmpty ? nil : parts.joined(separator: "\n") + } + nonisolated static func sleepDetectNoNightLogLine(day: String, hrCount: Int, rrCount: Int, respCount: Int, gravCount: Int, stepCount: Int, - providedCount: Int, windowHours: Int) -> String { + providedCount: Int, windowHours: Int, + skinCount: Int) -> String { + // `reason` names WHICH absence this is, because grav=0 is printed but its consequence is not. + // With no motion the stager has no HR-only fallback, so no quantity of HR can stage a night — a + // strap capability limit, not a coverage gap, and the two want completely different follow-ups. + // With motion present the inputs were there and staging still produced nothing, which is the case + // actually worth investigating. + let reason = gravCount == 0 ? "no-motion" : "staged-none" + // #1118 follow-up: name any stream that came back AT its read cap. A read that returns exactly the + // limit is the definition of truncated everywhere else here (`full.count >= limit`), and it is the + // one thing a reader cannot infer from the counts alone — `grav=192698` looks healthy until you + // know the cap it is 96% of. + // + // GRAVITY is why this exists. HR and R-R ride a SlidingStreamWindow, which counts its own + // truncations; gravity is a plain read with no counter at all, so a night clipped of its newest + // motion staged badly and said nothing. That is the difference between "this release fixes your + // night" and "your offload never ran", and a log could not tell them apart. + var atCap: [String] = [] + // ONLY the plain reads. Gravity and skin are read whole, so a result of exactly the cap IS the + // truncation — the same `count >= limit` test used everywhere else here, and neither stream has a + // counter of its own, which is why this marker exists. + // + // HR and R-R are deliberately absent even though their counts are printed. They arrive through + // `SlidingStreamWindow.rows`, which returns `full.filter { ts in from...to }` — a SLICE of a read + // that usually spans more than this night. A spliced window that WAS truncated still hands back a + // slice under the cap, so the marker would silently fail to fire: a false negative on a line whose + // only value is that its absence means "not clipped". Their exact truncation count is already + // printed unconditionally once per pass by `WindowedStreamPlan.logLine` (`hrTruncated=`), so + // nothing is lost by declining to guess it per night. + // + // resp and steps are plain reads too, and equally judgeable, but stay unmarked on purpose: neither + // is a staging input, so clipping one cannot be the reason a night is missing. Their counts are + // printed as context. The marker answers "did a stream that could explain this get cut short", + // not "was every read complete". + if gravCount >= StreamReadCap.gravity { atCap.append("grav") } + if skinCount >= StreamReadCap.skin { atCap.append("skin") } + let capNote = atCap.isEmpty ? "" : " atCap=" + atCap.joined(separator: ",") return "sleep-detect day=\(day) NO-NIGHT hr=\(hrCount) rr=\(rrCount) resp=\(respCount) " - + "grav=\(gravCount) steps=\(stepCount) provided=\(providedCount) window=\(windowHours)h" + + "grav=\(gravCount) skin=\(skinCount) steps=\(stepCount) provided=\(providedCount) " + + "window=\(windowHours)h reason=\(reason)" + capNote } /// #674/#1244: the "sleep total with no matched session" divergence line. A COMPUTED day whose fresh @@ -539,23 +592,22 @@ final class IntelligenceEngine: ObservableObject { let respCfg = Baselines.metricCfg["resp"], let skinCfg = Baselines.metricCfg["skin_temp"] else { return } - // #836 (idle-tick gate): re-scoring a 21-day window re-reads ~21×54 h of raw HR and re-runs + // #836 (idle-tick gate): re-scoring a 21-day window re-reads ~21×54 h of raw data and re-runs // analyzeDay over it. After a big Apple Health import (a reporter's: 2.1 M rows, ~190 k HR/day) that // is multi-second, memory-heavy work, and the 15-minute steady-state tick (AppModel) repeats it // every tick even when NOTHING new landed — the ongoing lag/crash in #836. A cheap whole-history HR - // fingerprint (count+maxTs, indexed, no rows materialized) lets a NON-forced caller short-circuit - // when the raw stream is byte-for-byte unchanged since the last successful run. All-or-nothing: it + // fingerprint (HR count+maxTs plus indexed row frontiers, no rows materialized) lets a NON-forced + // caller short-circuit when the raw streams are unchanged since the last successful run. All-or-nothing: it // never produces a PARTIAL pass, so the window-wide reconciliation (stale-day eviction, detected- // workout delete) is untouched and no computed history is dropped. Every real update path (sync // backfill, import, sleep/workout edit, baseline recalibrate, timestamp heal) calls with the default // `force: true` and always rescores, so a skipped tick can never hide new data. - // #1392: fingerprint the raw HR stream ACROSS ALL DEVICES, not the engine's construction-time + // #1392: fingerprint raw scoring streams ACROSS ALL DEVICES, not the engine's construction-time // `deviceId` (a `let` pinned to "my-whoop", never re-pointed when the active strap changes). On an // Oura / Apple Watch / re-added-WHOOP install the per-device read returned 0, so this gate never // fired and a night finishing after launch stayed unscored until relaunch. Scoring below still reads // the registry's ACTIVE device (`owner`); only this change-detector needed to be cross-device. - let wmKey: String = (try? await store.hrFingerprint()) - .map { "\($0.count):\($0.maxTs)" } ?? "" + let wmKey = (try? await store.analysisFingerprint()) ?? "" // #1538: read the stored watermark ONCE for both gates and the attribution line below. There is no // suspension point between them, so the three reads this replaces could not disagree — but the log // line asserting `newData` and the gate deciding whether to run must be the SAME comparison by @@ -565,7 +617,7 @@ final class IntelligenceEngine: ObservableObject { return } // #1196/#1146: a FORCED post-offload pass can opt into the same fingerprint gate. An empty/duplicate - // offload (fingerprint already == the watermark the last successful run advanced) has no new HR to + // offload (fingerprint already == the watermark the last successful run advanced) has no new raw data to // score, so a re-score would reproduce IDENTICAL rows; skip the whole pass rather than churn the // window. Over a flapping-link offload storm that churn made the reactive Trends/streak reads // flicker between full and empty — a scare that looked like data loss (#1196). Scoped via @@ -607,7 +659,18 @@ final class IntelligenceEngine: ObservableObject { // between here and there, so "started and never finished" means exactly "killed", never a silent // internal skip. `RescoreBackgroundPolicy` reads it to stop re-attempting a pass that cannot // finish in the background, which is the livelock in #1538. - RescoreBackgroundScheduler.markRescoreOwed() + // #1681: keep the token this debt was stamped with. At the end of the pass it is what tells our + // own debt apart from one a LATER trigger recorded while we were running - the latter must + // survive us, because the data it was recorded for arrived after we had already read our inputs. + // + // This is the SAME discipline the watermark beside it already used and the owed flag did not: + // capture the value at the start, compare against it at the end, never re-read. `wmKey` is read + // once at the top and the pass writes back THAT value, so HR arriving mid-pass leaves the + // watermark behind and the next trigger re-scores. The owed flag was the one piece of per-pass + // state that skipped the capture and just cleared, which is exactly where #1681 lived. The + // Kotlin post-offload gate makes the same point in its own words: "captured before the run, + // written only on success". + let owedToken = RescoreBackgroundScheduler.markRescoreOwed() // #899-A re-arm: clear the lock, then if a forced rescore was dropped while this pass held it, // run it ONCE. The flag is cleared BEFORE the re-invoke (a single re-arm), so a forced call landing // DURING the re-invoke re-arms it again but a quiet one does not , this can never recurse unbounded. @@ -717,7 +780,7 @@ final class IntelligenceEngine: ObservableObject { // and the Sleep tab resolve to the identical block. (#547) let (habitualMidsleepSec, nightlyHours) = await Self.computeHabitualSleep( store: store, importedId: deviceId, computedId: deviceId + "-noop", - windowStart: nowLocalMidnight - maxDays * 86_400 - 30 * 3_600, + windowStart: nowLocalMidnight - maxDays * 86_400 - StreamReadCap.lookbackSeconds, windowEnd: now, offsetSec: tzOffset) // Wave 0 (SL1/T1): personal sleep REGULARITY + population-anchored NEED, computed ONCE from the // trailing per-night durations and threaded to every analyzeDay below (mirrors the midsleep @@ -773,6 +836,7 @@ final class IntelligenceEngine: ObservableObject { // #1545: the Effort TRIMP recipe, read ONCE per pass. Global (same for every day), so it folds // into the config signature below rather than the per-day key. let effortMethodGlobal = PuffinExperiment.effortMethod + let dayCycleMode = DayCycleMode.persisted(UserDefaults.standard.string(forKey: DayCycleMode.storageKey)) // ── #1005 BATTERY: per-day reuse cache setup (see `dayScanCache`) ──────────────────────────── // The stager toggles are read per-day inside the loop below, but they are global (same value every @@ -825,6 +889,7 @@ final class IntelligenceEngine: ObservableObject { // produced under one method is stale the moment the user switches — serving it would show a // window of days scored by a recipe the user just turned off, with nothing to explain it. "\(effortMethodGlobal)", + dayCycleMode.rawValue, ].joined(separator: "|") // Drop the whole cache on a config change, then snapshot it into a Sendable `let` for the detached // loop (the engine is @MainActor; the loop can't touch `self`). The loop returns the updated cache @@ -851,7 +916,7 @@ final class IntelligenceEngine: ObservableObject { var skippedSleepDays: [(day: String, hrSamples: Int)] = [] // #938: the WHOOP 4.0 ADC offset is per-device, not per-night. Learn one anchor per owner // from the whole scan window and reuse it for every night so cross-night deviations survive. - let skinAnchorScanFrom = nowLocalMidnight - (maxDays - 1) * 86_400 - 30 * 3_600 + let skinAnchorScanFrom = nowLocalMidnight - (maxDays - 1) * 86_400 - StreamReadCap.lookbackSeconds let skinAnchorScanTo = nowLocalMidnight + 18 * 3_600 var skinAnchorByOwner: [String: Double] = [:] var skinAnchorResolvedOwners = Set() @@ -869,11 +934,33 @@ final class IntelligenceEngine: ObservableObject { // Together with `dayCacheReused` this is the honest denominator for the reuse ratio — see the // diagnostic at the end of the loop. var dayCacheCacheable = 0 + // #1538: backward sliding read buffers for the two heavy streams. Pass 1 walks backwards over + // 54-hour windows on a 24-hour stride, so consecutive days overlap by 30 hours and every row + // was being materialised ~2.25x per pass. These read the missing stride only; every case the + // planner cannot prove safe falls back to exactly the read that shipped before them. HR and + // R-R only: the other eight streams are thousands of rows against these two's tens of + // thousands, so this is nearly all of the win for two call sites of blast radius. + // Each window's read cap and its truncation threshold must be the SAME number: the window + // declines to slice a read that came back at the cap, because `ORDER BY ts ASC LIMIT` drops the + // NEWEST rows. Were the two to drift apart, a truncated read would stop being recognised and the + // buffer would be sliced while missing its tail — wrong scoring inputs, silently. Passing one + // `StreamReadCap` value into both slots below keeps that guarantee per stream. + // + // #1538: the two streams no longer share ONE cap. 200,000 was sized for HR (a full 54h window is + // ~194,400 rows, 3% under) and silently truncated R-R, which is per-BEAT and runs to 233k-389k + // over the same window. A field capture showed `rr[... truncated=10]` — ten nights scored on R-R + // missing its tail. See `StreamReadCap` for the arithmetic. + let hrWindow = SlidingStreamWindow(tsOf: { $0.ts }, limit: StreamReadCap.hr) { o, f, t in + try? await store.hrSamples(deviceId: o, from: f, to: t, limit: StreamReadCap.hr) + } + let rrWindow = SlidingStreamWindow(tsOf: { $0.ts }, limit: StreamReadCap.rr) { o, f, t in + try? await store.rrIntervals(deviceId: o, from: f, to: t, limit: StreamReadCap.rr) + } for offset in 0..= IntelligenceEngine.minHrSamples else { // This day still paid for its read; count it, or the tally under-reports exactly the // sparse-history installs where reads dominate most. @@ -952,7 +1050,7 @@ final class IntelligenceEngine: ObservableObject { skippedSleepDays.append((day: day, hrSamples: hr.count)) continue } - let rr = (try? await store.rrIntervals(deviceId: owner, from: from, to: to, limit: 200_000)) ?? [] + let rr = await rrWindow.rows(owner: owner, from: from, to: to) // `forScoring` drops an Oura ring's respiration rows: those are the ring's OWN per-window // RATE (0x6A, milli-bpm, ~1 row per 5 min), stored as instrumentation, while the stager // reads this stream as a ~1 Hz raw ADC waveform. Refusing by provenance keeps the @@ -970,9 +1068,10 @@ final class IntelligenceEngine: ObservableObject { limit: 200_000)) ?? [] let resp = OuraRespScale.forScoring(respRows, deviceId: owner) let vendorResp = OuraRespScale.forVendorRate(respRows, deviceId: owner) - let grav = (try? await store.gravitySamples(deviceId: owner, from: from, to: to, limit: 200_000)) ?? [] + let grav = (try? await store.gravitySamples(deviceId: owner, from: from, to: to, + limit: StreamReadCap.gravity)) ?? [] let steps = (try? await store.stepSamples(deviceId: owner, from: from, to: to, limit: 200_000)) ?? [] - let skin = (try? await store.skinTempSamples(deviceId: owner, from: from, to: to, limit: 200_000)) ?? [] + let skin = (try? await store.skinTempSamples(deviceId: owner, from: from, to: to, limit: StreamReadCap.skin)) ?? [] // #93: WHOOP 4.0 raw SpO2 PPG samples for the night; analyzeDay banks the nightly red/IR ADC // means on the DailyMetric. Empty on a 5/MG (no v24 spo2 channels) → the raw means stay nil. let spo2 = (try? await store.spo2Samples(deviceId: owner, from: from, to: to, limit: 200_000)) ?? [] @@ -1000,7 +1099,7 @@ final class IntelligenceEngine: ObservableObject { let windowSkin = (try? await store.skinTempSamples(deviceId: owner, from: skinAnchorScanFrom, to: skinAnchorScanTo, - limit: 200_000)) ?? [] + limit: StreamReadCap.skin)) ?? [] if let anchor = Whoop4SkinTemp.deviceAnchorRaw(windowSkin.map { $0.raw }) { skinAnchorByOwner[owner] = anchor } @@ -1117,17 +1216,58 @@ final class IntelligenceEngine: ObservableObject { // untouched; analyzeDay still lets a DETECTED session win where the two overlap. Reconstruct the // pure SleepSession from each stored CachedSleepSession (a minute-dict import row decodes to // nothing and is skipped, so only real stage timelines are injected). + // #804 Fix A + #1801. Two different questions share the "this day has no motion" gate. + // + // #804 hands over a device's OWN persisted hypnogram (an Oura ring's SleepNet night) and is + // deliberately not applied to the import namespace. #1801 stages from heart rate when there + // is no hypnogram at all — and must NOT inherit #804's owner exclusion, which is the bug + // this replaces: `resolveDayOwner` returns the imported id whenever the owner source is + // absent or the candidates collapse to it, so on a live 5/MG install the whole branch was + // skipped before any heart rate was looked at. + // + // The stored lookup now runs for every no-motion day, so "nothing else knows about this + // night" is checked rather than assumed. A day that HAS stored sessions is left alone + // whoever owns it. let providedSleep: [SleepSession] - if owner != Repository.whoopSource, grav.count < 2 { + if grav.count < 2 { let persisted = (try? await store.sleepSessions(deviceId: owner, from: from, to: to, limit: 4000)) ?? [] - providedSleep = persisted.compactMap { AnalyticsEngine.sleepSession(fromProvided: $0) } + let stored = persisted.compactMap { AnalyticsEngine.sleepSession(fromProvided: $0) } + if owner != Repository.whoopSource, !stored.isEmpty { + traceSink?(SleepStager.GateTrace.hrOnlyGateLine( + attempted: false, reason: "stored-hypnogram", + gravRows: grav.count, storedNights: stored.count)) + providedSleep = stored + } else if !stored.isEmpty { + traceSink?(SleepStager.GateTrace.hrOnlyGateLine( + attempted: false, reason: "stored-sessions-exist", + gravRows: grav.count, storedNights: stored.count)) + providedSleep = [] + } else { + // Reachable for ANY owner now, which widens this past the 5/MG it was built for: + // a WHOOP 4.0 day that banked nothing at all also lands here, where the owner + // check previously blocked it. A normal 4.0 day is untouched — it streams + // gravity, so it never reaches this gate. + traceSink?(SleepStager.GateTrace.hrOnlyGateLine( + attempted: true, reason: "no-motion-no-hypnogram", + gravRows: grav.count, storedNights: 0)) + providedSleep = SleepStager.hrOnlySessions(hr: hr, rr: rr, resp: resp, + traceSink: traceSink) + } } else { providedSleep = [] } + let tScore0 = Date() dayPrepSeconds += tScore0.timeIntervalSince(tPrep0) - let res = AnalyticsEngine.analyzeDay(day: day, hr: hr, rr: rr, resp: resp, + // #1770 follow-up: the Effort ring's funnel. Collected here rather than sent straight + // to `diagnosticSink`, because that sink is main-actor isolated and this loop is not — + // the same reason `hrvDiag` is carried on the scan and replayed below. A local buffer + // crosses no actor. + var strainDiagLines: [String] = [] + let res = AnalyticsEngine.analyzeDay(day: day, + strainDiag: { strainDiagLines.append($0) }, + hr: hr, rr: rr, resp: resp, vendorResp: vendorResp, gravity: grav, steps: steps, dayHr: dayHr, daySteps: daySteps, dayGravity: dayGrav, @@ -1192,7 +1332,8 @@ final class IntelligenceEngine: ObservableObject { hrvDiag = Self.sleepDetectNoNightLogLine( day: day, hrCount: hr.count, rrCount: rr.count, respCount: resp.count, gravCount: grav.count, stepCount: steps.count, - providedCount: providedSleep.count, windowHours: windowHours) + providedCount: providedSleep.count, windowHours: windowHours, + skinCount: skin.count) } else { hrvDiag = nil } @@ -1224,9 +1365,12 @@ final class IntelligenceEngine: ObservableObject { // covers 1.25x its wall-clock reads ~197 ms across a sleeping night, against a 40-100 ms // physiological range. Printing that number beside the verdict that says it cannot be // trusted invites it to be read as a measurement, so it is withheld instead; the - // `rrIntegrity=` field on the same line says why. RMSSD/meanNN are NOT withheld — mean - // rate survives an over-count, and RMSSD's dominant error was the emission order fixed - // at the write path (#1072). + // `rrIntegrity=` field on the same line says why. meanNN is NOT withheld: mean rate + // survives an over-count. RMSSD is not withheld FROM THIS LINE either, but it no longer + // reaches the card, the daily row or the baseline on an over-counted night — the gate + // that stops it lives in `SleepStager.sessionAvgHRV` (#1118). Printing the computed + // value here while the app refused to use it is a known wart, held back to keep this + // line byte-identical to the Kotlin twin, whose method budget has no room for it. // P7' follow-up: the over-count verdict is necessary but NOT sufficient. The // 2026-08-06 Oura night measured coverage 1.03 / `plausible` — no duplication at // all, its records tiling the timeline at a fill ratio of 0.990 — and still printed @@ -1352,11 +1496,18 @@ final class IntelligenceEngine: ObservableObject { // Computed here (pure inputs) and carried out so the main actor can replay it through // `diagnosticSink` in the SAME per-day order , the sink is a MainActor-bound closure. var rhrLine: String? + var rhrBinLine: String? if let floor = res.daily.restingHr { let inBedBpms = hr.filter { s in res.cachedSleep.contains { s.ts >= $0.startTs && s.ts < $0.endTs } }.map { $0.bpm } rhrLine = Self.rhrFloorMeanLogLine(day: res.daily.day, floor: floor, inBedBpms: inBedBpms) + // #1943: conformance check that the gate `sessionRestingHR` now applies agrees with + // the shipped floor. Silent when the gate is correctly applied. + rhrBinLine = SleepStager.rhrBinGateLogLine( + day: res.daily.day, + sessions: res.cachedSleep.map { ($0.startTs, $0.endTs) }, + hr: hr, shippedFloor: floor) } // #1331 respiratory diagnostic — a run of nil nights localises when it stopped. Same // pure-compute-here / replay-on-main-actor path as rhrLine. @@ -1406,10 +1557,12 @@ final class IntelligenceEngine: ObservableObject { // windowing + delegation lives in the byte-identical, tested `AnalyticsEngine`. let (primarySessionRHR, primarySessionRHRCoverage) = AnalyticsEngine.primarySessionRestingHRWithCoverage(sessions: res.sleepSessions, hr: hr) - let scan = DayScan(result: res, rhrLine: rhrLine, respLine: respLine, + let scan = DayScan(result: res, rhrLine: rhrLine, rhrBinLine: rhrBinLine, + respLine: respLine, readOwner: owner, hrRows: hr.count, sleepTrace: sleepTrace, stepsTrace: stepsTrace, hrvTrace: hrvTrace, - hrvDiag: hrvDiag, spo2Candidate: spo2CandidateMean, + hrvDiag: Self.mergedDayDiag(hrvDiag, strainDiagLines), + spo2Candidate: spo2CandidateMean, hrvOverCounted: hrvOverCounted, primarySessionRHR: primarySessionRHR, primarySessionRHRCoverage: primarySessionRHRCoverage) @@ -1447,6 +1600,15 @@ final class IntelligenceEngine: ObservableObject { // narrowing; `analyzeDay` dominating means it is not, whatever the row counts look like. skippedDayLines.append("analyzeRecent cost prep=\(Int(dayPrepSeconds * 1000))ms " + "score=\(Int(dayScoreSeconds * 1000))ms") + // #1538: what the sliding windows saved, beside the line the decision to build them was made + // from. A pass where `served` is ~0 means they are declining (truncation, an owner flip, or a + // gap left by a dayCache hit) and the reads are back to what they were — the honest outcome + // rather than a silent one. Byte-identical to the Kotlin `analyzeWindowsLogLine`. + skippedDayLines.append(WindowedStreamPlan.logLine( + hrRead: hrWindow.rowsRead, hrServed: hrWindow.rowsServed, + hrTruncated: hrWindow.truncatedReads, + rrRead: rrWindow.rowsRead, rrServed: rrWindow.rowsServed, + rrTruncated: rrWindow.truncatedReads)) return (out, skippedDayLines, dayScanCacheLocal) }.value // #1005: write the loop's updated reuse cache back to the (main-actor) stored property. The pass ran @@ -1502,6 +1664,7 @@ final class IntelligenceEngine: ObservableObject { primarySessionRHRCoverageByDay[res.daily.day] = cov } if let line = scan.rhrLine { diagnosticSink?(line, nil) } + if let line = scan.rhrBinLine { diagnosticSink?(line, nil) } if let line = scan.respLine { diagnosticSink?(line, nil) } // Sleep & Rest test mode (E5): replay this day's gate-trace + Rest lines tagged `.sleep` so they // land under the profile tag in the export. Empty unless the mode is active. @@ -1627,7 +1790,7 @@ final class IntelligenceEngine: ObservableObject { // re-labelled rows (both written under `deviceId`), and apple-health carries Health imports , // a detected bout overlapping ANY of them is skipped below. Port of the Android dedup block. // (`computedId` is bound once above, before the off-actor scan loop.) - let windowStart = now - maxDays * 86_400 - 30 * 3_600 + let windowStart = now - maxDays * 86_400 - StreamReadCap.lookbackSeconds var realWorkouts = (try? await store.workouts(deviceId: deviceId, from: windowStart, to: now, limit: 100_000)) ?? [] realWorkouts += (try? await store.workouts(deviceId: "apple-health", from: windowStart, @@ -1660,6 +1823,49 @@ final class IntelligenceEngine: ObservableObject { // already staged from raw (idempotent) and for imported nights (raw never dense). This MUST run // before the scoring loop so the healed stages flow into Rest/recovery this same pass. let editedRows = await repo.selfHealEditedStages(from: windowStart, to: now) + var cycleCandidates: [(owner: String, priority: Int)] = regDevices + .map { device in + let priority: Int + if device.id == regActiveId { priority = 0 } + else if device.status == .archived { priority = 4 } + else if device.sourceKind == .activityFile { priority = 3 } + else if device.isImportSource { priority = 2 } + else { priority = 1 } + return (device.id, priority) + } + if !cycleCandidates.contains(where: { $0.owner == regActiveId }) { + cycleCandidates.append((regActiveId, 0)) + } + let physiologyOwners = ([regActiveId] + regDevices.filter { + $0.brand.caseInsensitiveCompare("WHOOP") == .orderedSame + }.map(\.id) + [Repository.whoopSource]).reduce(into: [String]()) { + if !$0.contains($1) { $0.append($1) } + } + let cycleWorkouts = await repo.workoutRows(days: maxDays + 2) + let physiologicalSteps = await DayCycleIntelligenceIntegration.compute( + nights: scoredNights.map { night in + DayCycleIntelligenceIntegration.Night( + daily: night.daily, + sleeps: night.cachedSleep, + workouts: night.workouts, + owner: resolvedScoreOwnerByDay[night.daily.day] ?? regActiveId) + }, + editedRows: editedRows, + store: store, + candidates: cycleCandidates, + physiologyOwners: physiologyOwners, + workouts: cycleWorkouts, + windowStart: windowStart, + now: now, + offsetSec: tzOffset, + habitualMidsleepSec: habitualMidsleepSec, + ticksPerStep: up.stepTicksPerStep, + mode: dayCycleMode, + cache: dayCycleCache, + profile: up, + maxHROverride: maxHR, + effortMethod: effortMethodGlobal, + trace: stepsTraceActive ? { self.diagnosticSink?($0, .steps) } : nil) // #299: `editsByStart` is now built PER DAY inside the scoring loop (scoped to the day each edit // belongs to), NOT window-wide here. sleepEditedDaily folds any edited row that isn't a twin of THIS // day's detected sessions in as a "manual" block, so a window-wide edit set let ONE user edit / @@ -1704,8 +1910,9 @@ final class IntelligenceEngine: ObservableObject { // every night. `effectiveStartTs` (the #318 user-corrected onset) is preserved on the row. let dayEditedRows = Self.editedRowsForDay(editedRows, day: night.daily.day, tzOffsetSeconds: tzOffset) let editsByStart = Dictionary(dayEditedRows.map { ($0.startTs, $0) }, uniquingKeysWith: { a, _ in a }) - let daily = sleepEditedDaily(night.daily, detected: night.cachedSleep, editsByStart: editsByStart, + var daily = sleepEditedDaily(night.daily, detected: night.cachedSleep, editsByStart: editsByStart, habitualMidsleepSec: habitualMidsleepSec) + daily = DayCycleIntelligenceIntegration.applying(physiologicalSteps, to: daily) let recovery = recomputeRecovery(daily, baselines2) // Charge term-breakdown trace (Group G): only when the Recovery test mode is on. Emits which // term moved Charge and which was nil and forced the renorm, tagged `.recovery`. The trace's @@ -1725,7 +1932,7 @@ final class IntelligenceEngine: ObservableObject { // off the HRV baseline state rather than a blanket `.solid`, so a thin/provisional baseline shows // EST. not REL. Pure presentation upstream of the UI; the score itself is unchanged. let chargeConf = ScoreConfidence.charge(recovery: recovery, hrvBaseline: baselines2.hrv) - out.append(Computed(day: daily.day, recovery: recovery, strain: night.strain, + out.append(Computed(day: daily.day, recovery: recovery, strain: daily.strain, sleepMin: daily.totalSleepMin, hrv: daily.avgHrv, rhr: daily.restingHr, source: source, confidence: chargeConf, drivers: drivers, skinTempRel: skinRel)) @@ -1799,6 +2006,11 @@ final class IntelligenceEngine: ObservableObject { if let rest = AnalyticsEngine.Rest.composite(daily: daily) { restPoints.append(MetricPoint(day: daily.day, key: "sleep_performance", value: rest)) } + if let onset = physiologicalSteps.onsetByWakeDay[daily.day] { + restPoints.append(MetricPoint(day: daily.day, + key: DayCycleIntelligenceIntegration.onsetKey, + value: Double(onset))) + } // #103: persist the SpO₂ candidate @82 nightly mean to metricSeries as "spo2_candidate" so the // Blood Oxygen tile can surface it as a "strap estimate (unverified)" fallback when the toggle // is ON. Written under the "-noop" computed device ID, never to `spo2Pct` — the candidate has @@ -2003,13 +2215,29 @@ final class IntelligenceEngine: ObservableObject { provenanceByCell["\(point.day)\u{1F}\(point.key)"] = ScoreInputProvenanceRow(day: point.day, key: point.key, sourceId: source) } + let markerKeys: [String] + let markerPoints: [SourcedMetricPoint] + let markerSources: [String] + switch physiologicalSteps.markerUpdate { + case .preserve: + markerKeys = []; markerPoints = []; markerSources = [] + case let .replace(points, sourceIds): + markerKeys = [DayCycleIntelligenceIntegration.onsetKey] + markerPoints = points.map { + SourcedMetricPoint(deviceId: $0.deviceId, point: $0.point) + } + markerSources = sourceIds + } try? await store.persistComputedScores( dailyMetrics: dailies, metricPoints: restPoints, provenance: Array(provenanceByCell.values), deviceId: computedId, from: oldestDay, - to: newestDay + to: newestDay, + replaceMetricKeys: markerKeys, + additionalMetricPoints: markerPoints, + replaceMetricSourceIds: markerSources ) // Now evict only the STALE computed rows in the window , those a prior (e.g. UTC-keyed) run left @@ -2148,6 +2376,13 @@ final class IntelligenceEngine: ObservableObject { } return (refSteps, motion) }.value + // #1816: persist whether the strap has banked ANY motion in the calibration scan window, so the + // Today tile can distinguish "Need N more phone-step days" (motion exists, phone half missing) + // from "No motion synced yet" (the motion half is the blocker, and no number of phone-step days + // will move the estimate or the fit). A step estimate is `motion * coefficient`, so with the + // motion half missing the caption that names only the phone half is a lie — it sent a field + // reporter to enter Apple Health steps by hand expecting calibration to start, which it cannot. + profile.stepsHasBankedMotion = !motionByDay.isEmpty // Build calibration points only for days with BOTH a motion volume and a real phone step count. let calPoints = motionByDay.compactMap { (day, motion) -> StepsEstimateEngine.CalibrationPoint? in guard let s = refStepsByDay[day] else { return nil } @@ -2356,8 +2591,8 @@ final class IntelligenceEngine: ObservableObject { // Sleep tab stops showing the removed duplicates right away. if !dailies.isEmpty || !healDropped.isEmpty { await repo.refresh() } - // #836: record the raw-HR fingerprint this run scored against, so a later NON-forced tick can - // short-circuit while it's unchanged. Written ONLY here at the end of a completed run (never on an + // #836/#sleep-sync: record the complete raw-analysis fingerprint this run scored against, so a later + // NON-forced tick can short-circuit while it's unchanged. Written ONLY at the end of a completed run (never on an // early guard-return), so an interrupted/failed run can't advance the watermark past unscored data. if !wmKey.isEmpty { UserDefaults.standard.set(wmKey, forKey: Self.analyzeWatermarkKey) } // #1538: clear the started-mark and bank how long a COMPLETED pass costs on this install. The @@ -2365,13 +2600,20 @@ final class IntelligenceEngine: ObservableObject { // background wake from one that never could, instead of guessing from a constant — the cost varies // by more than an order of magnitude with history size. let elapsed = Date().timeIntervalSince(reScoreStart) - RescoreBackgroundScheduler.markRescoreCompleted(seconds: elapsed) + let settled = RescoreBackgroundScheduler.markRescoreCompleted(seconds: elapsed, owedToken: owedToken) diagnosticSink?("re-score: done — scored \(scoredNights.count) night(s) in \(Int(elapsed * 1000)) ms (#1005)", nil) + // #1681: a pass that completes while leaving the mark SET looks identical in a capture to one that + // cleared it. Rare-event evidence, so always-on: it costs a line only when it actually happens, + // and it is exactly what is missing when someone reports the app re-scoring on every launch. + if !settled { + diagnosticSink?("re-score: debt NOT settled — a newer re-score was recorded while this pass " + + "was running, so the mark stays and another pass will run (#1681)", nil) + } } - /// UserDefaults key for the #836 idle-tick gate: the `(count:maxTs)` HR fingerprint the last completed + /// UserDefaults key for the #836 idle-tick gate: the complete raw-analysis fingerprint the last completed /// `analyzeRecent` scored against. A non-forced tick whose current fingerprint equals this skips the - /// 21-day rescore; cleared implicitly by any HR insert/delete (the fingerprint moves), so it self-heals. + /// 21-day rescore; cleared implicitly by any scoring-stream insert (the fingerprint moves), so it self-heals. private static let analyzeWatermarkKey = "noop.analyzeWatermark" /// CAPTURE-B (#814/#799): build the universal `dayOwner …` self-diagnostic line VERBATIM (the Test @@ -2807,7 +3049,8 @@ extension DailyMetric { avgHrv: avgHrv, recovery: r, strain: strain, exerciseCount: exerciseCount, spo2Pct: spo2Pct, skinTempDevC: sd, respRateBpm: respRateBpm, steps: steps, activeKcalEst: activeKcalEst, - spo2Red: spo2Red, spo2Ir: spo2Ir, avgSdnn: avgSdnn, skinTempC: sa) + spo2Red: spo2Red, spo2Ir: spo2Ir, avgSdnn: avgSdnn, skinTempC: sa, + sleepHrOnly: sleepHrOnly) } /// Rebuild with substituted sleep-derived fields (a user-corrected wake window), leaving every @@ -2819,7 +3062,7 @@ extension DailyMetric { strain: strain, exerciseCount: exerciseCount, spo2Pct: spo2Pct, skinTempDevC: skinTempDevC, respRateBpm: respRateBpm, steps: steps, activeKcalEst: activeKcalEst, spo2Red: spo2Red, spo2Ir: spo2Ir, avgSdnn: avgSdnn, - skinTempC: skinTempC) + skinTempC: skinTempC, sleepHrOnly: sleepHrOnly) } } diff --git a/Strand/Data/KeyMetricPrefs.swift b/Strand/Data/KeyMetricPrefs.swift index 91b050395a..49d12d8cd6 100644 --- a/Strand/Data/KeyMetricPrefs.swift +++ b/Strand/Data/KeyMetricPrefs.swift @@ -26,6 +26,11 @@ enum KeyMetric: String, CaseIterable, Identifiable { case steps case weight case calories + /// Added 2026-08-24 (queue 11c follow-up): Skin Temp was already a "Your Cards" (`DashboardCard`) + /// option, but was never offered as a Key Metrics tile — not a bug, just never added. New case, NOT + /// added to `defaultOrder` below, so an existing user's saved layout (and a fresh install's default) + /// is byte-identical to before; only opts in via the layout editor. + case skinTemp var id: String { rawValue } @@ -42,6 +47,9 @@ enum KeyMetric: String, CaseIterable, Identifiable { case .steps: return String(localized: "Steps") case .weight: return String(localized: "Weight") case .calories: return String(localized: "Calories") + // Same short label the sibling "Your Cards" tile (`DashboardCard.skinTemp`) already uses — + // reuses its translation key rather than adding a new "Skin Temperature" one for this tile. + case .skinTemp: return String(localized: "Skin Temp") } } diff --git a/Strand/Data/Profile.swift b/Strand/Data/Profile.swift index 46ac4316dc..7e95727ccc 100644 --- a/Strand/Data/Profile.swift +++ b/Strand/Data/Profile.swift @@ -56,6 +56,16 @@ final class ProfileStore: ObservableObject { @Published var stepsCalibrationManual: Bool { didSet { d.set(stepsCalibrationManual, forKey: K.stepsManualFlag) } } /// User-set manual coefficient. 0 = auto-fit (nil to the engine); > 0 = manual override. @Published var stepsManualCoefficient: Double { didSet { d.set(max(0, stepsManualCoefficient), forKey: K.stepsManualCoeff) } } + /// #1816: true when the strap has banked ANY motion (gravity samples → `dayMotionIntensity > 0`) + /// in the calibration scan window. Written by `IntelligenceEngine` on every analytics pass so it + /// tracks a fresh strap's first sync without a separate query. The Today tile reads this to decide + /// whether "Need N more days where your phone also counted steps" is the honest caption or a lie: + /// a step estimate is `motion * coefficient`, so with the motion half missing neither the estimate + /// nor the fit moves however many phone-counted days the user collects. The caption that names only + /// the phone half is actively misleading — it sent a field reporter to enter Apple Health steps by + /// hand expecting calibration to start, which it cannot without strap motion. Twin of Android's + /// `ProfileStore.stepsHasBankedMotion`. + @Published var stepsHasBankedMotion: Bool { didSet { d.set(stepsHasBankedMotion, forKey: K.stepsHasMotion) } } // ── Profile picture (optional, on-device only) ────────────────────────────────────────────── /// The user's chosen profile photo as JPEG bytes, or nil for the default SF-Symbol fallback. @@ -85,6 +95,7 @@ final class ProfileStore: ObservableObject { static let stepsConfidence = "profile.stepsCalibrationConfidence" static let stepsManualFlag = "profile.stepsCalibrationManual" static let stepsManualCoeff = "profile.stepsManualCoefficient" + static let stepsHasMotion = "profile.stepsHasBankedMotion" static let avatar = "profile.avatarImageData" } @@ -124,6 +135,7 @@ final class ProfileStore: ObservableObject { stepsCalibrationConfidence = d.object(forKey: K.stepsConfidence) as? Double ?? 0 stepsCalibrationManual = d.object(forKey: K.stepsManualFlag) as? Bool ?? false stepsManualCoefficient = max(0, d.object(forKey: K.stepsManualCoeff) as? Double ?? 0) + stepsHasBankedMotion = d.object(forKey: K.stepsHasMotion) as? Bool ?? false avatarImageData = d.data(forKey: K.avatar) } diff --git a/Strand/Data/Repository.swift b/Strand/Data/Repository.swift index 1e6d455d4e..96f0bf5c54 100644 --- a/Strand/Data/Repository.swift +++ b/Strand/Data/Repository.swift @@ -266,6 +266,34 @@ final class Repository: ObservableObject { // MARK: - Union reads (active strap + canonical) // + /// Raw physiological streams are a worn timeline, not a property of whichever strap is active now. + /// Resolve every registered physical WHOOP, active first, and keep the canonical import namespace as + /// the final fallback. Archived devices intentionally remain: archive means "stop connecting, keep + /// data", and historical timelines must not orphan their retained samples. The active id remains first + /// even for a non-WHOOP provider, preserving the pre-multi-strap cross-provider path. + private func rawPhysiologyReadIds(store: WhoopStore) -> [String] { + let paired = (try? DeviceRegistryStore(dbQueue: store.registryWriter).all()) ?? [] + let registeredWhoops = paired.filter { + $0.brand.caseInsensitiveCompare("WHOOP") == .orderedSame + }.map(\.id) + return Self.rawWhoopSourceIds(activeDeviceId: deviceId, registeredWhoopIds: registeredWhoops) + } + + /// Pure ordering contract shared with Android's parity guard: current active source first, every other + /// registered WHOOP in stable registry order, canonical history last; duplicates collapse. + nonisolated static func rawWhoopSourceIds(activeDeviceId: String, + registeredWhoopIds: [String]) -> [String] { + let canonical = Self.whoopSource + let middle = registeredWhoopIds.filter { $0 != activeDeviceId && $0 != canonical } + return ([activeDeviceId] + middle + [canonical]).reduce(into: [String]()) { + if !$0.contains($1) { $0.append($1) } + } + } + + private func rawComputedReadIds(store: WhoopStore) -> [String] { + rawPhysiologyReadIds(store: store).map { $0.hasSuffix("-noop") ? $0 : $0 + "-noop" } + } + // Each helper reads the SAME store query across `importedReadIds` (active strap + canonical "my-whoop") // and concatenates the rows, ACTIVE STRAP FIRST. The callers feed the concatenated rows into the SAME // per-day dedup they already used (mergeDaily/mergeSleep key by day; the daily/series facades dedup @@ -285,6 +313,61 @@ final class Repository: ObservableObject { return byDay.values.sorted { $0.day < $1.day } } + /// Gravity samples across the imported union, deduped by timestamp with the ACTIVE STRAP winning. + /// + /// #1643: the Steps calibration screen read a SINGLE id and so disagreed with the estimator, which + /// resolves an owner per day before reading gravity. Android hit the canonical half of this (it + /// hardcoded "my-whoop" and missed a re-added strap's live motion, @kavemang's #1644); the Swift + /// screen had the mirror half — it read the ACTIVE id and so missed the canonical history a prior + /// import or an earlier strap identity had banked. + /// + /// Twin of Kotlin `WhoopRepository.gravitySamplesUnion`. It takes the active id as a parameter + /// because its repository is not device-scoped; this one already knows `importedReadIds`. + func gravitySamplesUnion(from: Int, to: Int, limit: Int = 200_000) async -> [GravitySample] { + guard let store = await storeHandle() else { return [] } + var lists: [[GravitySample]] = [] + for id in rawPhysiologyReadIds(store: store) { // active strap FIRST → it wins any shared timestamp + lists.append((try? await store.gravitySamples(deviceId: id, from: from, to: to, limit: limit)) ?? []) + } + return Self.mergeGravityByTs(lists) + } + + /// Merge gravity lists into one time-ordered stream, deduped by timestamp with the FIRST list (the + /// active strap) winning a tie. A single-id read is returned UNCHANGED, so a single-device install + /// performs exactly the read it did before the union existed. + /// + /// Byte-identical rule to Kotlin `mergeGravityByTs`. Pure + static so the dedup is unit-tested + /// without a store — the property that actually matters here is "active wins, nothing double-counts". + nonisolated static func mergeGravityByTs(_ lists: [[GravitySample]]) -> [GravitySample] { + if lists.count == 1 { return lists[0] } + var byTs: [Int: GravitySample] = [:] + for list in lists { + for s in list where byTs[s.ts] == nil { byTs[s.ts] = s } + } + return byTs.values.sorted { $0.ts < $1.ts } + } + + /// Merge R-R reads without treating a timestamp as a beat identity. Several real beats can share a + /// one-second timestamp; only the complete storage identity `(ts, rrMs, seq)` is a duplicate across + /// namespaces. The first list is the active strap and therefore wins exact duplicates. + nonisolated static func mergeRRByIdentity(_ lists: [[RRInterval]]) -> [RRInterval] { + if lists.count == 1 { return lists[0] } + struct BeatKey: Hashable { let ts: Int; let rrMs: Int; let seq: Int } + var seen = Set() + var out: [RRInterval] = [] + for list in lists { + for beat in list where seen.insert(BeatKey(ts: beat.ts, rrMs: beat.rrMs, seq: beat.seq)).inserted { + out.append(beat) + } + } + return out.sorted { + if $0.ts != $1.ts { return $0.ts < $1.ts } + if $0.seq != $1.seq { return $0.seq < $1.seq } + if $0.rrMs != $1.rrMs { return $0.rrMs < $1.rrMs } + return ($0.ord ?? Int.min) < ($1.ord ?? Int.min) + } + } + /// One day held by two source ids in the SAME bucket, folded into `winner`'s row: `winner` keeps every /// column it carries (NON-nil — a measured zero is a reading) and `filler` supplies only the ones it /// left nil. Whole-row first-wins let a hollow row (steps and nothing else) discard a complete one, and @@ -320,7 +403,11 @@ final class Repository: ObservableObject { spo2Ir: rawSpo2FromFiller ? filler.spo2Ir : winner.spo2Ir, // Strap-only, like raw SpO2: an imported winner carries no absolute skin temp, so take the // filler's rather than let the union blank a value the strap did record (#1636). - skinTempC: winner.skinTempC ?? filler.skinTempC + skinTempC: winner.skinTempC ?? filler.skinTempC, + // Part of the SLEEP GROUP, not an independent column (#1801): it describes how the stage + // figures above were derived, so it has to come from whichever row supplied them. A plain + // `winner ?? filler` would caption the winner's own hypnogram with the filler's staging. + sleepHrOnly: sleepFromFiller ? filler.sleepHrOnly : winner.sleepHrOnly ) } @@ -336,11 +423,11 @@ final class Repository: ObservableObject { return byDay.values.sorted { $0.day < $1.day } } - /// Sleep sessions across the imported union for a ts range, keeping ALL sessions per day (a nap + a main + /// Sleep sessions across every registered WHOOP source for a ts range, keeping ALL sessions per day (a nap + a main /// night both survive) and dropping only EXACT-duplicate blocks (same start+end) recorded under both /// union ids. The downstream `mergeSleep`/`userEditedDays` do the per-day collapse, exactly as before. private func unionSleepSessions(store: WhoopStore, from: Int, to: Int, limit: Int = 4000) async -> [CachedSleepSession] { - Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: importedReadIds, from: from, to: to, limit: limit)) + Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: rawPhysiologyReadIds(store: store), from: from, to: to, limit: limit)) } /// Computed ("-noop") daily-metric rows across the computed union, DEDUPED per day (active strap's @@ -355,10 +442,10 @@ final class Repository: ObservableObject { return byDay.values.sorted { $0.day < $1.day } } - /// Computed ("-noop") sleep sessions across the computed union, keeping ALL sessions per day and dropping + /// Computed ("-noop") sleep sessions across every registered WHOOP source, keeping ALL sessions per day and dropping /// only EXACT-duplicate blocks recorded under both computed siblings. private func unionComputedSleepSessions(store: WhoopStore, from: Int, to: Int, limit: Int = 4000) async -> [CachedSleepSession] { - Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: computedReadIds, from: from, to: to, limit: limit)) + Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: rawComputedReadIds(store: store), from: from, to: to, limit: limit)) } /// ALL sleep blocks across `ids` for a ts range, concatenated (NOT collapsed to one per day, used by @@ -525,6 +612,23 @@ final class Repository: ObservableObject { DailyMetric.lastSkinTempDay(days: days, todayKey: todayKey) } + /// The freshest strictly-prior row with EITHER skin-temp number (#1844), for the surfaces that lead + /// with the absolute. See `DailyMetric.lastSkinTempReadingDay`. + nonisolated static func lastSkinTempReadingDay(days: [DailyMetric], todayKey: String) -> DailyMetric? { + DailyMetric.lastSkinTempReadingDay(days: days, todayKey: todayKey) + } + + /// PER-FIELD HRV carry — twin of the above, for a field `lastVitalsDay`'s OR predicate DOES check but + /// can still resolve nil on (#1842). See `DailyMetric.lastHrvDay`. + nonisolated static func lastHrvDay(days: [DailyMetric], todayKey: String) -> DailyMetric? { + DailyMetric.lastHrvDay(days: days, todayKey: todayKey) + } + + /// PER-FIELD resting-HR carry — twin of `lastHrvDay`. See `DailyMetric.lastRestingHrDay`. + nonisolated static func lastRestingHrDay(days: [DailyMetric], todayKey: String) -> DailyMetric? { + DailyMetric.lastRestingHrDay(days: days, todayKey: todayKey) + } + /// PER-FIELD respiratory carry — twin of `lastSpo2Day`, but STALENESS-BOUNDED to `Baselines.vitalCarryDays`. /// SpO₂/skin-temp are sparse/imported so last-known-of-any-age is expected; respiratory is nightly, so a /// weeks-old value shown as the current number is the "Respiratory 15.6 a fortnight later" bug that @@ -1026,11 +1130,12 @@ final class Repository: ObservableObject { // UNION the active strap + canonical so the HR trend renders whether the landed day's raw sits under // the re-added strap (live) or the canonical history. Deduped by ts (active strap wins) so an overlap // never double-counts; sorted ascending. Single-device install reads one id (byte-identical). - guard deviceId != canonicalDeviceId else { + let ids = rawPhysiologyReadIds(store: store) + guard ids.count != 1 else { return (try? await store.hrSamples(deviceId: deviceId, from: from, to: to, limit: limit)) ?? [] } var byTs: [Int: HRSample] = [:] - for id in importedReadIds { + for id in ids { for s in (try? await store.hrSamples(deviceId: id, from: from, to: to, limit: limit)) ?? [] where byTs[s.ts] == nil { byTs[s.ts] = s } @@ -1038,6 +1143,21 @@ final class Repository: ObservableObject { return byTs.values.sorted { $0.ts < $1.ts } } + /// R-R beats across the active physical WHOOP and canonical history. Exact duplicates are removed + /// active-first, while same-timestamp distinct beats remain intact. + func rrIntervals(from: Int, to: Int, limit: Int = 8000) async -> [RRInterval] { + guard let store = await ensureStore() else { return [] } + let ids = rawPhysiologyReadIds(store: store) + guard ids.count != 1 else { + return (try? await store.rrIntervals(deviceId: deviceId, from: from, to: to, limit: limit)) ?? [] + } + var lists: [[RRInterval]] = [] + for id in ids { + lists.append((try? await store.rrIntervals(deviceId: id, from: from, to: to, limit: limit)) ?? []) + } + return Self.mergeRRByIdentity(lists) + } + /// Logical day-start of the most recent day the active device has HR data for, or nil when the store is /// empty. Lets the Deep Timeline open on a day that actually has data instead of a possibly-empty today /// right after a history sync , the #597 root cause (the timeline was today-only with no way back). @@ -1069,19 +1189,11 @@ final class Repository: ObservableObject { func hrBuckets(from: Int, to: Int, bucketSeconds: Int = 300) async -> [HRBucket] { guard let store = await ensureStore() else { return [] } - // UNION the active strap + canonical for the trend chart. Per bucket-start the active strap wins; a + // UNION all registered WHOOP straps + canonical for the trend chart. Per bucket-start the active strap wins; a // raw HR window almost never overlaps between the two namespaces (different time periods), so the // per-bucket mean stays faithful. Single-device install reads one id (byte-identical). - guard deviceId != canonicalDeviceId else { - return (try? await store.hrBuckets(deviceId: deviceId, from: from, to: to, bucketSeconds: bucketSeconds)) ?? [] - } - var byStart: [Int: HRBucket] = [:] - for id in importedReadIds { - for b in (try? await store.hrBuckets(deviceId: id, from: from, to: to, bucketSeconds: bucketSeconds)) ?? [] where byStart[b.ts] == nil { - byStart[b.ts] = b - } - } - return byStart.values.sorted { $0.ts < $1.ts } + return await hrBuckets(deviceIds: rawPhysiologyReadIds(store: store), from: from, to: to, + bucketSeconds: bucketSeconds) } /// The latest (greatest-ts) non-nil @63 activity class over `[from, to]`, read across the active strap + @@ -1165,11 +1277,13 @@ final class Repository: ObservableObject { guard let store = await ensureStore() else { return [] } let now = Int(Date().timeIntervalSince1970) let lo = now - days * 86_400, hi = now + 86_400 + let rawIds = rawPhysiologyReadIds(store: store) + let rawComputedIds = rawComputedReadIds(store: store) // UNION the active strap + canonical (imported) and their computed siblings, keeping ALL blocks (not // one per day, this view expands split sleeps), but dropping any block that appears under BOTH union // ids (same start+end key) so a day present in both namespaces isn't double-listed. - let imported = Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: importedReadIds, from: lo, to: hi)) - let computed = Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: computedReadIds, from: lo, to: hi)) + let imported = Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: rawIds, from: lo, to: hi)) + let computed = Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: rawComputedIds, from: lo, to: hi)) let cal = Calendar.current func endDay(_ s: CachedSleepSession) -> Date { cal.startOfDay(for: Date(timeIntervalSince1970: TimeInterval(s.endTs))) @@ -1187,12 +1301,36 @@ final class Repository: ObservableObject { /// already chosen the main-night GROUP (the 6.1.1 bridged group) and passes those blocks' starts; we /// only fetch each one's stored series so the Sleep tab can lay them along the hypnogram's timeline. /// A start with no stored series is omitted from the result (its key is absent). - func sessionMotions(starts: [Int]) async -> [Int: [Double]] { - guard !starts.isEmpty, let store = await ensureStore() else { return [:] } - // One batched read keyed by startTs, not a single-row SELECT per session start. The store's - // batched accessor keeps the exact contract of the old loop: starts with no (or an empty) series - // are omitted from the result. - return (try? await store.sessionMotions(deviceId: computedDeviceId, sessionStarts: starts)) ?? [:] + func sessionMotions(sessions: [CachedSleepSession]) async -> [Int: [Double]] { + guard !sessions.isEmpty, let store = await ensureStore() else { return [:] } + let rawIds = rawPhysiologyReadIds(store: store) + let computedIds = rawComputedReadIds(store: store) + var out: [Int: [Double]] = [:] + for session in sessions where out[session.startTs] == nil { + // CachedSleepSession has no provenance field. Re-resolve the exact visible block using the + // same imported-wins order as allSleepSessions, then probe its computed owner first. + var owner: String? + for id in rawIds + computedIds { + let rows = (try? await store.sleepSessions(deviceId: id, from: session.startTs, + to: session.startTs, limit: 4)) ?? [] + if rows.contains(where: { $0.startTs == session.startTs && $0.endTs == session.endTs }) { + owner = id + break + } + } + let ownerComputed = owner.map { $0.hasSuffix("-noop") ? $0 : $0 + "-noop" } + let sources = ([ownerComputed].compactMap { $0 } + computedIds).reduce(into: [String]()) { + if !$0.contains($1) { $0.append($1) } + } + for id in sources { + if let motion = (try? await store.sessionMotion(deviceId: id, sessionStart: session.startTs)) ?? nil, + !motion.isEmpty { + out[session.startTs] = motion + break + } + } + } + return out } /// The user's learned habitual midsleep (local time-of-day seconds), or nil under @@ -1209,10 +1347,12 @@ final class Repository: ObservableObject { guard let store = await ensureStore() else { return nil } let now = Int(Date().timeIntervalSince1970) let lo = now - days * 86_400, hi = now + 86_400 - // UNION active strap + canonical (imported) and their computed siblings, de-duplicating identical - // blocks recorded under both ids so a day present in both namespaces doesn't double-weight the learner. - let imported = Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: importedReadIds, from: lo, to: hi)) - let computed = Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: computedReadIds, from: lo, to: hi)) + // Use the SAME all-registered-WHOOP source set as allSleepSessions. Otherwise a removed/replaced + // strap's visible retained nights would not teach the selector that chooses their main block. + let rawIds = rawPhysiologyReadIds(store: store) + let rawComputedIds = rawComputedReadIds(store: store) + let imported = Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: rawIds, from: lo, to: hi)) + let computed = Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: rawComputedIds, from: lo, to: hi)) let offsetSec = TimeZone.current.secondsFromGMT() let blocks = (imported + computed).compactMap { s -> SleepStageTotals.HistoryBlock? in let start = s.effectiveStartTs, end = s.endTs @@ -1686,11 +1826,9 @@ final class Repository: ObservableObject { func timelineSeries(metric: TimelineMetric, from: Int, to: Int, targetPoints: Int = 600, source: String? = nil) async -> TimelineSeries { guard to > from, let store = await ensureStore() else { return .empty } - // Default (no explicit source) → the user's own strap, UNIONed with the canonical "my-whoop" so the - // Deep Timeline renders whether the landed day's raw sits under the re-added strap or the canonical - // history. An explicit source (a per-source page) reads that source verbatim. `unionIds` is one id - // on a single-device install (byte-identical) and dedups raw by ts (active strap wins). - let unionIds: [String] = source.map { [$0] } ?? importedReadIds + // Default (no explicit source) → the complete worn WHOOP timeline: active, every registered prior + // strap, then canonical history. An explicit per-source page still reads that source verbatim. + let unionIds: [String] = source.map { [$0] } ?? rawPhysiologyReadIds(store: store) let bucket = Self.timelineBucketSeconds(spanSeconds: to - from, targetPoints: targetPoints) let isRaw = bucket <= 1 @@ -2126,7 +2264,9 @@ final class Repository: ObservableObject { /// byte-identical. The flag is forwarded to the non-strap `series(...)` delegation below so every source /// path honours it. func exploreSeries(key: String, source: String, days: Int = 4000, fullHistory: Bool = false) async -> [(day: String, value: Double)] { - guard source == "my-whoop" else { return await series(key: key, source: source, days: days, fullHistory: fullHistory) } + guard source == "my-whoop" else { + return Self.oneSkinTempScale(key: key, await series(key: key, source: source, days: days, fullHistory: fullHistory)) + } guard let store = await ensureStore() else { return [] } let now = Date() let from = fullHistory ? "0000-01-01" : Self.dayString(now.addingTimeInterval(-Double(days) * 86_400)) @@ -2153,7 +2293,29 @@ final class Repository: ObservableObject { for p in (try? await store.metricSeries(deviceId: id, key: key, from: from, to: to)) ?? [] { byDay[p.day] = p.value } } - return byDay.sorted { $0.key < $1.key }.map { (day: $0.key, value: $0.value) } + return Self.oneSkinTempScale(key: key, byDay.sorted { $0.key < $1.key }.map { (day: $0.key, value: $0.value) }) + } + + /// #1705: reduce a `skin_temp` window to a single scale; every other key passes through untouched. + /// + /// The key is bimodal — a CSV import writes ABSOLUTE °C where the computed layers write a baseline + /// DEVIATION, so one window can hold both. Every consumer of an Explore series aggregates it + /// (min/max/mean, the window-over-window delta, the correlation scan), and those are arithmetic + /// across two scales: on the reported account a few ~34 °C readings lifted a should-be-near-zero + /// average past a full degree, and the mean then read below 20 so it was labelled Δ°C. + /// + /// Applied on BOTH of `exploreSeries`' return paths, deliberately. Today the only `skin_temp` + /// descriptor is `my-whoop`, so the delegating path is unreachable for it — but a guard that is + /// correct only because of a fact in another file is how this bug happened in the first place. + /// `series(day:value:)` is ordered ascending by day, which `dominantKind` relies on. + private static func oneSkinTempScale( + key: String, + _ series: [(day: String, value: Double)] + ) -> [(day: String, value: Double)] { + guard key == "skin_temp", + let keep = SkinTempDisplay.dominantKind(valuesAscendingByDay: series.map(\.value)) + else { return series } + return series.filter { SkinTempDisplay.kind(of: $0.value) == keep } } /// Every catalog metric's Explore series at once, memoized — the cross-catalog scan behind the @@ -2432,13 +2594,16 @@ final class Repository: ObservableObject { guard let store = await ensureStore() else { return [] } let now = Int(Date().timeIntervalSince1970) let lo = now - days * 86_400, hi = now + 86_400 - // UNION the active strap + canonical (and their computed siblings) so workouts banked under the - // canonical "my-whoop" before a re-add still show, alongside the re-added strap's live workouts. + // UNION every registered WHOOP + canonical (and computed siblings) so workouts banked before a + // re-add remain visible alongside every retained strap's live workouts. // De-dup identical same-source rows that appear under both union ids by natural key (the cross-SOURCE // dedup below only collapses strap-vs-Apple twins, not a row present in two strap namespaces). var rows: [WorkoutRow] = [] - for id in importedReadIds { rows += (try? await store.workouts(deviceId: id, from: lo, to: hi, limit: 5000)) ?? [] } - for id in computedReadIds { rows += (try? await store.workouts(deviceId: id, from: lo, to: hi, limit: 5000)) ?? [] } + let rawIds = rawPhysiologyReadIds(store: store) + for id in rawIds { rows += (try? await store.workouts(deviceId: id, from: lo, to: hi, limit: 5000)) ?? [] } + for id in rawIds.map({ $0.hasSuffix("-noop") ? $0 : $0 + "-noop" }) { + rows += (try? await store.workouts(deviceId: id, from: lo, to: hi, limit: 5000)) ?? [] + } rows += (try? await store.workouts(deviceId: "apple-health", from: lo, to: hi, limit: 5000)) ?? [] // Imported lifting sessions (Hevy / Liftosaur) live under their own "lifting" source. rows += (try? await store.workouts(deviceId: "lifting", from: lo, to: hi, limit: 5000)) ?? [] @@ -3050,7 +3215,11 @@ final class Repository: ObservableObject { } } -private extension DailyMetric { +// `internal`, not `private`: `DailySkinTempAbsoluteCarryTests` exercises these two directly, and a +// `private` extension is invisible even to `@testable import`. They stayed private because the tests +// that call them have never compiled (StrandTests runs only in the on-demand app-build), so nothing +// ever demanded the wider access. Still confined to this module. +extension DailyMetric { /// A copy of self where every nil field is backfilled from `fallback`. Used by the field-by-field /// daily merge so an imported export keeps its own values while a computed row fills the gaps it /// doesn't carry (e.g. on-device Charge / skin-temp deviation / activity totals). @@ -3080,7 +3249,10 @@ private extension DailyMetric { avgSdnn: avgSdnn ?? fallback.avgSdnn, // On-device only (imports never carry it), so an imported row's nil is backfilled from the // computed fallback — otherwise the night's absolute would be lost. (#1636) - skinTempC: skinTempC ?? fallback.skinTempC + skinTempC: skinTempC ?? fallback.skinTempC, + // Same shape, same reason (#1801): only a scoring pass knows how the night was staged, so an + // imported row's nil takes the computed answer rather than erasing it. + sleepHrOnly: sleepHrOnly ?? fallback.sleepHrOnly ) } @@ -3110,7 +3282,11 @@ private extension DailyMetric { spo2Red: spo2Red, // non-sleep field: preserved as-is (#93) spo2Ir: spo2Ir, avgSdnn: avgSdnn, // non-sleep (HRV) field: preserved as-is - skinTempC: skinTempC // non-sleep (thermal) field: preserved as-is (#1636) + skinTempC: skinTempC, // non-sleep (thermal) field: preserved as-is (#1636) + // MOVES with the sleep columns, unlike the others here: it describes how the very stage + // figures being taken were derived, so leaving `self`'s behind would caption the source's + // hypnogram with the import's staging — and an import's is always nil. (#1801) + sleepHrOnly: source.sleepHrOnly ) } } diff --git a/Strand/Data/SkinTempBackfillWalker.swift b/Strand/Data/SkinTempBackfillWalker.swift new file mode 100644 index 0000000000..55b5131783 --- /dev/null +++ b/Strand/Data/SkinTempBackfillWalker.swift @@ -0,0 +1,264 @@ +import Foundation +import StrandAnalytics +import WhoopStore +import WhoopProtocol + +/// #1853: the Apple walker that backfills `skinTempC` for nights the 21-night `analyzeRecent` rescore +/// window never reaches. The pure half (session attribution, the plan, rule 3) lives in +/// `StrandAnalytics/SkinTempBackfill.swift`; this file is the I/O layer that reads from / writes to +/// `WhoopStore`. +/// +/// The walker mirrors the scoring pass's per-night reads (same window, same stream limits, same +/// device-family / anchor / tolerance resolution) and calls the SAME `skinTempFunnel` the engine +/// uses, so a backfilled absolute is byte-identical to what the scoring pass would have stored. +/// +/// Trigger: ON-DEMAND from the Test Centre (the issue's "diagnostic first" + safest trigger). Doing +/// it automatically on upgrade risks a long first launch on a deep history. The caller decides how +/// many pages to run; one page is 50 nights by default. +/// +/// Non-negotiable: a fill-only write (`WhoopStore.fillSkinTempC`) that can only fill a NULL, never an +/// upsert of a rebuilt row. `skinTempDevC` and every other scored field are untouched. +/// An `actor`, deliberately, not a `@MainActor` class. This walks up to ten pages of fifty nights and +/// reads three streams for each, and while every `await` yields the main thread, each RESUMPTION and all +/// the per-night work between them would land on it. There is no UI state here - the store is an actor, +/// `DeviceRegistryStore` is a Sendable struct, and the result types are Sendable - so nothing needs the +/// main actor, and a long diagnostic pass has no business competing with the UI for it. +actor SkinTempBackfillWalker { + + /// One night that filled — the day and the re-derived absolute. + struct FilledNight: Sendable { + let day: String + let skinTempC: Double + } + + /// One night that declined — the day and the reason, for the diagnostic report. + struct DeclinedNight: Sendable { + let day: String + let reason: String + } + + /// The result of one backfill pass (one or more pages). + struct PassResult: Sendable { + /// Nights that filled (day → absolute °C). + let filled: [FilledNight] + /// Nights that declined (day → reason), for the diagnostic report. + let declined: [DeclinedNight] + /// Nights skipped because no raw `skinTempSample` rows exist for the window (the data is gone, + /// not the backfill's fault — see the issue's "Diagnostic first" note). + let noRawData: [String] + /// Whether another page might yield more fills (false when the last page was empty). + let moreRemaining: Bool + + var totalAttempted: Int { filled.count + declined.count + noRawData.count } + } + + private let store: WhoopStore + private let computedId: String + + init(store: WhoopStore) { + self.store = store + self.computedId = Repository.whoopSource + "-noop" + } + + /// Run one page of the backfill. The caller calls this repeatedly with increasing `page` until + /// `moreRemaining` is false or a budget is reached. + /// + /// - Parameters: + /// - page: 0-based page index (rule 2: page the candidates so the sweep doesn't latch on the + /// oldest N). + /// - pageSize: nights per page (default 50). + /// - windowAnchorRaw: the per-device WHOOP 4.0 anchor learned from the CURRENT scoring window + /// (rule 3). The caller obtains this from the same window-wide scan the scoring pass uses. + /// nil for a 5/MG or when the current window couldn't learn one. + /// - tzOffsetSeconds: seconds east of UTC, for local-day → unix-seconds conversion. + func runPage(page: Int, pageSize: Int = 50, + windowAnchorRaw: Double?, + tzOffsetSeconds: Int) async -> PassResult { + // 1. Find candidate nights: computed rows with a deviation but no absolute. + let candidates = await candidateNights() + let pageCandidates = SkinTempBackfill.page(candidates, page: page, pageSize: pageSize) + if pageCandidates.isEmpty { + return PassResult(filled: [], declined: [], noRawData: [], moreRemaining: false) + } + + // 2. Resolve the device family + worn tolerance for the owner (single-owner install: the + // canonical "my-whoop"). The registry is read once, mirroring the scoring pass. + let registry = DeviceRegistryStore(dbQueue: store.registryWriter) + let regDevices = (try? registry.all()) ?? [] + let activeId = (try? registry.activeDeviceId()) ?? Repository.whoopSource + + var filled: [FilledNight] = [] + var declined: [DeclinedNight] = [] + var noRawData: [String] = [] + + for candidate in pageCandidates { + // Resolve the day owner for this night (same resolver the scoring pass uses). + let owner = await IntelligenceEngine.resolveDayOwner( + day: candidate.day, from: candidate.from, to: candidate.to, + store: store, devices: regDevices, activeId: activeId, + registry: registry, fallbackDeviceId: Repository.whoopSource) + + let family = IntelligenceEngine.skinTempFamily(forOwner: owner, devices: regDevices) + let tolerance = IntelligenceEngine.skinTempWornToleranceSec(forOwner: owner, devices: regDevices) + + // Read the raw streams for this night's window — same limits the scoring pass uses. + let skin = (try? await store.skinTempSamples( + deviceId: owner, from: candidate.from, to: candidate.to, + limit: StreamReadCap.skin)) ?? [] + if skin.isEmpty { + noRawData.append(candidate.day) + continue + } + let hr = (try? await store.hrSamples( + deviceId: owner, from: candidate.from, to: candidate.to, + limit: StreamReadCap.hr)) ?? [] + // Read stored sleep sessions for the window (the backfill takes the night's stored sleep + // window, no re-detection — the issue's "take the night's stored sleep window from + // sleepSession"). + let persisted = (try? await store.sleepSessions( + deviceId: owner, from: candidate.from, to: candidate.to, + limit: 4000)) ?? [] + let sessions = persisted.compactMap { AnalyticsEngine.sleepSession(fromProvided: $0) } + + // Compute the local-midnight unix seconds for this day (the day key is local, so the + // midnight is the UTC midnight of (dayStart + tzOffset)). The scoring pass uses the same + // shift: `dayString(ts, offsetSec:) == day` ⇔ `(ts + offsetSec) ∈ [dayStart, +86400)`. + let dayStart = Self.localMidnightUnix(forDay: candidate.day, tzOffsetSeconds: tzOffsetSeconds) + + let result = SkinTempBackfill.computeNight( + candidate: candidate, + sessions: sessions, + hr: hr, + skinTemp: skin, + family: family, + windowAnchorRaw: windowAnchorRaw, + dayStart: dayStart, + wornToleranceSec: tolerance) + + if let abs = result.skinTempC { + // Fill-only write: can only fill a NULL, never overwrite a measured value. + _ = try? await store.fillSkinTempC(deviceId: computedId, day: candidate.day, skinTempC: abs) + filled.append(FilledNight(day: candidate.day, skinTempC: abs)) + } else { + declined.append(DeclinedNight(day: candidate.day, reason: result.declineReason ?? "unknown")) + } + } + + let moreRemaining = pageCandidates.count == pageSize + return PassResult(filled: filled, declined: declined, noRawData: noRawData, + moreRemaining: moreRemaining) + } + + /// Find computed `DailyMetric` rows that have a `skinTempDevC` but no `skinTempC` — the nights + /// the backfill can fill. Oldest-first (the issue pages oldest→newest so the sweep reaches the + /// newer nights that can fill). + private func candidateNights() async -> [SkinTempBackfill.NightCandidate] { + // Read all computed rows (the backfill window is unbounded — the issue's "how far back" is + // every night with samples). A wide day range covers the full local-history span. + let rows = (try? await store.dailyMetrics( + deviceId: computedId, from: "0000-01-01", to: "9999-12-31")) ?? [] + let tzOffset = TimeZone.current.secondsFromGMT() + let now = Int(Date().timeIntervalSince1970) + // Candidate: has a deviation (the night was scored) but no absolute (the column was null). + // Build the night's read window the same way the scoring pass does. + return rows + .filter { $0.skinTempDevC != nil && $0.skinTempC == nil } + .sorted { $0.day < $1.day } + .map { row in + let dayStart = Self.localMidnightUnix(forDay: row.day, tzOffsetSeconds: tzOffset) + let from = dayStart - StreamReadCap.lookbackSeconds + let to = min(dayStart + 86_400, now) // don't read past now for today's partial night + return SkinTempBackfill.NightCandidate(day: row.day, from: from, to: to, + owner: Repository.whoopSource) + } + } + + /// Local-midnight unix seconds for a yyyy-MM-dd day key. The day key is LOCAL, so midnight is the + /// UTC midnight of `(dayStart + tzOffset)`: `dayString(ts, offsetSec:) == day` ⇔ + /// `(ts + offsetSec) ∈ [dayStart, +86400)`, so `dayStart = utcMidnight(day) - tzOffset`. + static func localMidnightUnix(forDay day: String, tzOffsetSeconds: Int) -> Int { + // Parse the yyyy-MM-dd key to a UTC midnight, then shift by the tz offset to get local midnight. + let parts = day.split(separator: "-") + guard parts.count == 3, + let y = Int(parts[0]), let m = Int(parts[1]), let d = Int(parts[2]) else { return 0 } + var comps = DateComponents() + comps.year = y; comps.month = m; comps.day = d + comps.hour = 0; comps.minute = 0; comps.second = 0 + comps.timeZone = TimeZone(identifier: "UTC") + let utcMidnight = Calendar(identifier: .gregorian).date(from: comps) ?? Date() + return Int(utcMidnight.timeIntervalSince1970) - tzOffsetSeconds + } + + // MARK: - Self-contained entry point (the call site) + + /// The aggregate result of a full backfill pass (one or more pages), for the Test Centre report. + struct BackfillResult: Sendable { + let filled: [FilledNight] + let declined: [DeclinedNight] + let noRawData: [String] + /// The window anchor that was used (nil for a 5/MG or when the current window couldn't learn one). + let windowAnchorRaw: Double? + /// Whether the pass reached the end (false when a page was empty) or hit the page budget. + let reachedEnd: Bool + var filledCount: Int { filled.count } + var declinedCount: Int { declined.count } + var noRawDataCount: Int { noRawData.count } + } + + /// Run the full backfill: resolve the WHOOP 4.0 window anchor from the CURRENT scoring window + /// (rule 3 — the same window-wide scan the engine uses), then page through the candidate nights + /// until a page is empty or `maxPages` is reached. This is the single entry point the Test Centre + /// action calls; it owns the anchor scan so the call site does not duplicate engine internals. + /// + /// `maxDays` mirrors the scoring pass's window (default 21). `maxPages` caps the per-pass cost + /// (default 10 pages × 50 nights = 500 nights, plenty for any install). + func runBackfill(maxDays: Int = 21, maxPages: Int = 10) async -> BackfillResult { + let tzOffset = TimeZone.current.secondsFromGMT() + let now = Int(Date().timeIntervalSince1970) + let nowLocalMidnight = Self.localMidnightUnix( + forDay: Self.dayKey(now: now, tzOffsetSeconds: tzOffset), tzOffsetSeconds: tzOffset) + // Rule 3: the anchor comes from the CURRENT scoring window, learned window-wide (not per-night). + // Same window bounds the engine's `analyzeRecent` uses for `skinAnchorScanFrom`/`skinAnchorScanTo`. + let scanFrom = nowLocalMidnight - (maxDays - 1) * 86_400 - StreamReadCap.lookbackSeconds + let scanTo = nowLocalMidnight + 18 * 3_600 + let registry = DeviceRegistryStore(dbQueue: store.registryWriter) + let activeId = (try? registry.activeDeviceId()) ?? Repository.whoopSource + let regDevices = (try? registry.all()) ?? [] + let family = IntelligenceEngine.skinTempFamily(forOwner: activeId, devices: regDevices) + // Only a 4.0 needs the anchor; a 5/MG (centidegree path) passes nil and proceeds. + let windowAnchorRaw: Double? + if family == .whoop4 { + let windowSkin = (try? await store.skinTempSamples( + deviceId: activeId, from: scanFrom, to: scanTo, + limit: StreamReadCap.skin)) ?? [] + windowAnchorRaw = Whoop4SkinTemp.deviceAnchorRaw(windowSkin.map { $0.raw }) + } else { + windowAnchorRaw = nil + } + var filled: [FilledNight] = [] + var declined: [DeclinedNight] = [] + var noRawData: [String] = [] + var reachedEnd = false + for page in 0.. String { + let comps = Calendar(identifier: .gregorian).dateComponents( + [.year, .month, .day], from: Date(timeIntervalSince1970: TimeInterval(now + tzOffsetSeconds))) + let y = comps.year ?? 0, m = comps.month ?? 0, d = comps.day ?? 0 + return String(format: "%04d-%02d-%02d", y, m, d) + } +} diff --git a/Strand/Data/SleepLayoutPrefs.swift b/Strand/Data/SleepLayoutPrefs.swift index 6980e65358..75e6cab182 100644 --- a/Strand/Data/SleepLayoutPrefs.swift +++ b/Strand/Data/SleepLayoutPrefs.swift @@ -29,6 +29,7 @@ import SwiftUI enum SleepSection: String, CaseIterable, Identifiable { case sleepMarks case stages + case bodyClock case nightDetail case sleepDebt case stagesVsTypical @@ -41,6 +42,7 @@ enum SleepSection: String, CaseIterable, Identifiable { switch self { case .sleepMarks: return String(localized: "Sleep marks") case .stages: return String(localized: "Stages") + case .bodyClock: return String(localized: "Body clock") case .nightDetail: return String(localized: "Night detail") case .sleepDebt: return String(localized: "Sleep-debt ledger") case .stagesVsTypical: return String(localized: "Stages vs typical") @@ -53,7 +55,7 @@ enum SleepSection: String, CaseIterable, Identifiable { /// with Stages for now — it's drawn inside the stages hero; making it an independently arrangeable /// card is a follow-up that requires hoisting the hero's edit/delete callbacks.) static let defaultOrder: [SleepSection] = [ - .sleepMarks, .stages, .nightDetail, .sleepDebt, .stagesVsTypical, .asleepDuration, + .sleepMarks, .stages, .bodyClock, .nightDetail, .sleepDebt, .stagesVsTypical, .asleepDuration, ] } diff --git a/Strand/Data/SleepMark.swift b/Strand/Data/SleepMark.swift index 0dc79a9e61..9ad460c965 100644 --- a/Strand/Data/SleepMark.swift +++ b/Strand/Data/SleepMark.swift @@ -107,10 +107,7 @@ struct SleepMark: Equatable, Sendable { /// Device-locale clock for the log line ("11:42 PM" / "23:42") — follows the 12-/24-hour setting, /// matching the Sleep screen's Asleep/Woke row. - private static let clockFormatter: DateFormatter = { - let f = DateFormatter() - f.locale = AppLanguage.activeLocale - f.setLocalizedDateFormatFromTemplate("jmm") - return f - }() + /// #1821: routed through AppClock so the Clock format setting reaches this label. Was a `static + /// let`, which would have frozen the reader's choice at first use until the app relaunched. + private static var clockFormatter: DateFormatter { AppClock.hourMinuteFormatter() } } diff --git a/Strand/Data/Units.swift b/Strand/Data/Units.swift index b5dd1d90ac..04d4a6b5cb 100644 --- a/Strand/Data/Units.swift +++ b/Strand/Data/Units.swift @@ -1,24 +1,25 @@ import Foundation +import StrandAnalytics // MARK: - Unit system preference // // NOOP stores EVERYTHING in SI (km, kg, cm, °C) — the importers normalise on the way in, so this is a // purely cosmetic, display-only layer. There is no data migration and nothing on disk changes when the -// user flips this. We keep one Metric/Imperial switch for length+mass with a SEPARATE temperature -// override, because plenty of people think in kg/cm but still read body temperature in °F (and vice -// versa). Default is Metric — most of the world, and it matches what we store. +// user flips this. Body measurements and exercise distance each have their own Metric/Imperial choice, +// with a SEPARATE temperature override, because regional conventions commonly mix pounds with kilometres +// (or kg/cm with °F). Default is Metric — most of the world, and it matches what we store. // // Persisted via @AppStorage (UserDefaults), the same mechanism every other macOS NOOP preference uses. // The Android side mirrors this exactly in Units.kt + NoopPrefs. -/// The length+mass unit system. Temperature has its own override (see `UnitPrefs.temperature`). +/// A Metric/Imperial display choice. Body measurements and exercise distance persist separate values. enum UnitSystem: String, CaseIterable, Identifiable { case metric case imperial var id: String { rawValue } - /// "follow the system" pairs temperature with the length/mass choice; an explicit case lets the - /// user pin °C or °F independently of whether distances are in km or miles. + /// "Follow body measurements" pairs temperature with the body choice; an explicit case lets the + /// user pin °C or °F independently. var temperatureMatching: TemperatureUnit { self == .imperial ? .fahrenheit : .celsius } } @@ -78,12 +79,20 @@ enum HrvWindow: String, CaseIterable, Identifiable { var label: String { self == .deep ? "Deep sleep" : "Whole night" } } -/// UserDefaults keys for the two unit preferences. Public-ish (internal) so `SettingsView`'s -/// `@AppStorage(UnitPrefs.systemKey)` and the formatter read the SAME key — no drift. +/// UserDefaults keys and backwards-compatible preference resolution. enum UnitPrefs { + /// The original combined preference now owns body measurements. Keeping its key preserves every + /// existing user's kg/cm or lb/ft-in choice without a migration. static let systemKey = "units.system" - /// Temperature override. Empty string = "match the length/mass system" (the default). + /// Exercise distance + pace. Empty/unset follows `systemKey`, preserving the pre-#1913 behaviour. + static let distanceSystemKey = "units.distance" + /// Temperature override. Empty string = "follow body measurements" (the default). static let temperatureKey = "units.temperature" + + /// #1846: which skin-temp number the cards lead with — absent/`""` = a temperature (the default), or + /// `SkinTempDisplay.Kind.deviation.rawValue` to lead with the ±baseline move. Display-only; nothing + /// stored ever changes. Same key string as the Android `units.skinTempDisplay` pref. + static let skinTempDisplayKey = "units.skinTempDisplay" /// Effort display scale (#268). Stored raw is an `EffortScale` rawValue; an unset/unknown value /// resolves to `.hundred` (NOOP's native axis). Mirrored on Android by NoopPrefs("effort.scale"). static let effortScaleKey = "effort.scale" @@ -115,13 +124,18 @@ enum UnitPrefs { ? true : UserDefaults.standard.bool(forKey: liveActivityKey) } - /// Resolve the stored raw values into a concrete temperature unit, applying the - /// "match the system" default when no explicit override is set. + /// Resolve temperature, following body measurements when no explicit override is set. static func resolveTemperature(system: UnitSystem, override raw: String) -> TemperatureUnit { if let explicit = TemperatureUnit(rawValue: raw) { return explicit } return system.temperatureMatching } + /// Resolve the exercise-distance system. Existing installs have no distance key, so an empty or + /// unknown value follows the original combined preference until the user chooses independently. + static func resolveDistance(system: UnitSystem, override raw: String) -> UnitSystem { + UnitSystem(rawValue: raw) ?? system + } + /// Resolve the stored Effort-scale raw value, defaulting to NOOP's native 0–100 axis. static func resolveEffortScale(_ raw: String) -> EffortScale { EffortScale(rawValue: raw) ?? .hundred @@ -195,6 +209,15 @@ enum UnitFormatter { system == .imperial ? "mi" : "km" } + /// Format speed stored in kilometres/hour in the exercise-distance system. + static func speedFromKilometersPerHour(_ kmh: Double?, system: UnitSystem) -> String? { + guard let kmh, kmh.isFinite, kmh >= 0 else { return nil } + switch system { + case .metric: return oneDecimal(kmh) + " km/h" + case .imperial: return oneDecimal(kmToMiles(kmh)) + " mph" + } + } + // MARK: Mass (stored kg) /// kg → pounds. @@ -252,13 +275,43 @@ enum UnitFormatter { } } + /// °C delta → °F delta: a DIFFERENCE scales by 9/5 with NO +32 offset (the offset cancels between + /// the two absolute temperatures a delta is made of). Exposed as a Double because callers that need + /// locale-aware decimals — the illness-signal label formats through `AppLanguage.activeLocale` so a + /// German reader sees "0,7" — have to do their own formatting and would otherwise inline the 9/5, + /// scattering the one rule that must not drift. + static func celsiusDeltaToFahrenheit(_ dc: Double) -> Double { dc * 9.0 / 5.0 } + /// Format a temperature DEVIATION (a ±Δ°C, e.g. the skin-temp deviation pipeline). A delta scales by /// 9/5 but does NOT add the +32 offset — that would be wrong for a difference. static func temperatureDeltaFromCelsius(_ dc: Double, unit: TemperatureUnit, decimals: Int = 1) -> String { switch unit { case .celsius: return decimalString(dc, decimals) + " °C" - case .fahrenheit: return decimalString(dc * 9.0 / 5.0, decimals) + " °F" + case .fahrenheit: return decimalString(celsiusDeltaToFahrenheit(dc), decimals) + " °F" + } + } + + /// The skin-temperature phrase used by the illness-signal label — number AND unit chip, ready to + /// interpolate into one `%@`. + /// + /// #111/#622: the field is BIMODAL. An imported night carries an ABSOLUTE wrist °C, a live one a + /// signed DEVIATION from baseline, and they share a column — so the +32 offset belongs to only one + /// of them, and the chip is "°F" for one and "Δ°F" for the other. Both come from `SkinTempDisplay`, + /// the same authority the Today and Health tiles use. + /// + /// The NUMBER is formatted here rather than by `SkinTempDisplay.format` purely so it can honour + /// [locale]: the banner formats through `AppLanguage.activeLocale`, and a German reader must see + /// "0,7" where the package's plain `String(format:)` would give "0.7". Pass nil for POSIX decimals. + static func skinTempSignalPhrase(_ value: Double, fahrenheit: Bool, locale: Locale?) -> String { + let kind = SkinTempDisplay.kind(of: value) + let shown: Double + if fahrenheit { + shown = kind == .absolute ? celsiusToFahrenheit(value) : celsiusDeltaToFahrenheit(value) + } else { + shown = value } + let number = String(format: kind == .absolute ? "%.1f" : "%+.1f", locale: locale, shown) + return number + " " + SkinTempDisplay.unitSymbol(kind: kind, fahrenheit: fahrenheit) } /// Temperature unit label only. "°C" / "°F". diff --git a/Strand/Data/UpdateStore.swift b/Strand/Data/UpdateStore.swift index af0c1ac8ae..b0a6b27077 100644 --- a/Strand/Data/UpdateStore.swift +++ b/Strand/Data/UpdateStore.swift @@ -15,6 +15,10 @@ struct UpdateItem: Identifiable, Codable, Equatable { case whatsNew // a release note (seeded from AppChangelog on first run after an update) case reading // new data arrived (e.g. "N days backfilled") — links to Trends case strapAlert // a strap-side heads-up (low battery, sync) — informational + /// A NEWER RELEASE exists that this install does not have (#1659). Deliberately not `.whatsNew`: + /// that one means "here is what you just got", this one means "here is what you have not got yet", + /// and giving them the same row would make the bell ambiguous at the moment it matters most. + case newVersion } let id: UUID @@ -131,7 +135,10 @@ final class UpdateStore: ObservableObject { } private static func isInformational(_ kind: UpdateItem.Kind) -> Bool { - kind == .reading || kind == .whatsNew + // `.newVersion` is informational: it dedupes and is evictable like the others. It cannot spam on + // its own (UpdateAvailability posts once per version), but a user who ignores several releases + // should not have them crowd out the actionable rows. + kind == .reading || kind == .whatsNew || kind == .newVersion } /// Trim the informational backlog to the newest `maxItems`. Actionable rows (`.dismissedCard`, diff --git a/Strand/Liquid/LiquidTodayView.swift b/Strand/Liquid/LiquidTodayView.swift index 28d5e0280d..96eb35c357 100644 --- a/Strand/Liquid/LiquidTodayView.swift +++ b/Strand/Liquid/LiquidTodayView.swift @@ -17,6 +17,8 @@ import WhoopStore import StrandAnalytics struct LiquidTodayView: View { + @AppStorage(DayCycleMode.storageKey) private var dayCycleModeRaw = DayCycleMode.sleepOnset.rawValue + private var dayCycleMode: DayCycleMode { DayCycleMode.persisted(dayCycleModeRaw) } @EnvironmentObject var repo: Repository @EnvironmentObject var router: NavRouter @EnvironmentObject var profile: ProfileStore @@ -73,6 +75,8 @@ struct LiquidTodayView: View { // sheets / expanders @State private var guideSection: ScoreSection? @State private var customizationDestination: TodayCustomizationDestination? + /// #1862: the optional Coach launcher sheet. Presentation state only — opening it requests nothing. + @State private var showCoachLauncher = false @State private var showSettings = false @State private var synthesisExpanded = false @State private var showLiveSession = false @@ -99,6 +103,11 @@ struct LiquidTodayView: View { @State private var kSparks: [String: [(String, Double)]] = [:] private var enabledKeyMetrics: [KeyMetric] { KeyMetricPrefs.decodeEnabled(keyMetricsRaw) } + /// #1001: TODAY's in-progress Effort, scored live in `load()` over the same window this view already + /// resolves for its other reads. nil for a navigated past day, and nil when the scorer has too few + /// readings — every Effort read-out then falls back to the stored row rather than a fabricated value. + @State private var liveTodayStrain: Double? + // day navigation (0 = today, 1 = yesterday, …) @State private var selectedDayOffset = 0 @State private var showDayPicker = false @@ -114,6 +123,9 @@ struct LiquidTodayView: View { /// resolved in body — body rescans repo.days ~23× per pass, and this cache keeps that read O(1). @State private var cachedVitalsDay: DailyMetric? @State private var cachedRespDay: DailyMetric? + @State private var cachedHrvDay: DailyMetric? + @State private var cachedRestingHrDay: DailyMetric? + @State private var cachedSkinTempReadingDay: DailyMetric? /// The Charge hero's resolved state (#543 carry + the honest label), resolved ONCE in load() alongside /// the other caches. It composes `TodayView.lastScoredRecoveryDay`, which is O(days) — exactly the scan /// this cache exists to keep out of body. Never resolved in body. @@ -178,6 +190,25 @@ struct LiquidTodayView: View { /// The prior-day RESPIRATORY carry (#1331): staleness-bounded, so a recent missed night reads the last /// real value while a weeks-old one honestly shows "No Data". Non-nil only at offset 0. private var respDay: DailyMetric? { cachedRespDay } + + /// PER-FIELD HRV / resting-HR carries (#1842), read O(1) from the cache like `vitalsDay`. `vitalsDay`'s + /// predicate is an OR across HRV / resting-HR / respiratory, so it resolves the freshest row with ANY of + /// them — a respiratory-only row blanks HRV and Resting HR on both the vitals card and the Key Metrics + /// tiles. Twins of `DailyMetric.lastHrvDay` / `lastRestingHrDay`; mirror the Android per-field rows. + private var hrvDay: DailyMetric? { cachedHrvDay } + + private var restingHrDay: DailyMetric? { cachedRestingHrDay } + + /// The skin-temp reading these cards LEAD with (#1844): today's row if it holds either number, else the + /// vitals carry, else the freshest prior row with either. Both numbers come off the SAME row, so an + /// absolute is never paired with another night's deviation. Twin of `TodayView.skinTempLeadReading`. + private var skinTempLeadReading: SkinTempDisplay.Reading? { + let row = [displayDay, vitalsDay, cachedSkinTempReadingDay] + .compactMap { $0 } + .first { $0.skinTempC != nil || $0.skinTempDevC != nil } + return SkinTempDisplay.leadReading(absC: row?.skinTempC, devC: row?.skinTempDevC, + prefer: SkinTempDisplay.Kind(rawValue: skinTempDisplayRaw) ?? .absolute) + } /// The Charge hero's resolved state (see `cachedChargeDisplay`), read O(1) from the cache. private var chargeDisplay: ChargeDisplay { cachedChargeDisplay } @@ -394,7 +425,9 @@ struct LiquidTodayView: View { .liquidMediumHaptic(trigger: pullHaptic) // hydrationSeq joins the id so logging a drink re-reads the card immediately, the same trigger set // classic TodayView's reloadHydration() uses. - .task(id: "\(repo.refreshSeq)-\(selectedDayOffset)-\(repo.hydrationSeq)-\(hydrationEnabled)") { await load() } + .task(id: "\(repo.refreshSeq)-\(selectedDayOffset)-\(repo.hydrationSeq)-\(hydrationEnabled)-\(dayCycleModeRaw)") { + await load() + } .sheet(item: $guideSection) { section in NavigationStack { ScoringGuideView(initialSection: section, onClose: { guideSection = nil }) } } @@ -410,6 +443,9 @@ struct LiquidTodayView: View { hostedCardsRaw: $hostedCardsRaw ) } + .sheet(isPresented: $showCoachLauncher) { + CoachLauncherSheet() + } .sheet(isPresented: $showSettings) { NavigationStack { SettingsView() @@ -453,7 +489,16 @@ struct LiquidTodayView: View { private func handlePull(_ y: CGFloat) { pullY = max(0, y) guard !refreshing else { return } - if pullY >= pullThreshold, !refreshArmed { + // #1748 twin: gate the ARM, not the release. `syncNow()`'s own gate checks connected + bonded, and + // `bonded` is set by the live-HR path for a 5/MG that has never completed a handshake — so the pull + // was accepted and then declined in silence. `historyReady` is the client's OWN precondition, so + // this cannot withhold a sync that would have run. + // + // On the ARM specifically: gating the RELEASE below would leave `refreshArmed` stuck true for the + // rest of the gesture, since that branch is the only thing that clears it — a worse failure than + // the silent one being fixed. Not arming also withholds the haptic, which is the honest signal + // that the gesture is unavailable rather than unresponsive. + if pullY >= pullThreshold, !refreshArmed, ble.state.historyReady { refreshArmed = true pullHaptic &+= 1 } @@ -620,7 +665,7 @@ struct LiquidTodayView: View { // one decimal on the compressed 0–21 axis to match the app-wide `effortDisplay` convention // (12.6, not a rounded "13"); the 0–100 hero stays a whole number as before. HeroScoreCell(label: String(localized: "Effort"), - score: displayDay?.strain.map { UnitFormatter.effortValue($0, scale: effortScale) }, + score: effortStrain(displayDay).map { UnitFormatter.effortValue($0, scale: effortScale) }, tint: StrandPalette.effortColor, animated: dataLoaded, onGuide: { guideSection = .effort }, maxValue: effortScale == .whoop ? 21 : 100, @@ -922,9 +967,10 @@ struct LiquidTodayView: View { // // frac stays nil deliberately. A signed deviation has no natural 0–100 fill, and a ring drawn // from one would imply a magnitude the number does not carry. - let skin = displayDay?.skinTempDevC ?? vitalsDay?.skinTempDevC + // #1844: lead with the night's measured ABSOLUTE when it has one; deviation nights unchanged. + let skin = skinTempLeadReading cardLink(.metric("skin_temp"), title: card.title, sub: card.subtitle, - value: TodayView.skinTempCardValue(skin, fahrenheit: temperatureUnit == .fahrenheit), + value: TodayView.skinTempCardValue(reading: skin, fahrenheit: temperatureUnit == .fahrenheit), tint: StrandPalette.metricAmber, frac: nil) case .calories: // #616: show the resolved imported-first value and route to the matching detail source, like @@ -951,6 +997,11 @@ struct LiquidTodayView: View { // A tap-through to the full Coupled day screen. No value. cardLink(.coupled, title: card.title, sub: card.subtitle, value: "", tint: StrandPalette.chargeColor, frac: 0.6) + case .coach: + // #1862: a sheet rather than a push — the point of the card is to try Coach WITHOUT + // leaving Today. + cardAction(title: card.title, sub: card.subtitle, value: "", + tint: StrandPalette.accent, frac: 0.5) { showCoachLauncher = true } } } @@ -959,7 +1010,26 @@ struct LiquidTodayView: View { private func cardLink(_ route: TabRoute, title: String, sub: String, value: String, tint: Color, frac: Double?) -> some View { NavigationLink(value: route) { - HStack(spacing: 12) { + cardLinkBody(title: title, sub: sub, value: value, tint: tint, frac: frac) + } + .buttonStyle(LiquidPressStyle()) + } + + /// The same row, running `action` instead of pushing (#1862). Coach is the one dashboard card that + /// opens a SHEET, so it cannot ride `NavigationLink`; sharing `cardLinkBody` keeps the two kinds of + /// row from drifting apart visually. + private func cardAction(title: String, sub: String, value: String, tint: Color, frac: Double?, + action: @escaping () -> Void) -> some View { + Button(action: action) { + cardLinkBody(title: title, sub: sub, value: value, tint: tint, frac: frac) + } + .buttonStyle(LiquidPressStyle()) + } + + @ViewBuilder + private func cardLinkBody(title: String, sub: String, value: String, + tint: Color, frac: Double?) -> some View { + HStack(spacing: 12) { LiquidVessel(value: frac, tint: tint, animated: false).frame(width: 30, height: 30) VStack(alignment: .leading, spacing: 1) { Text(title.uppercased()).font(StrandFont.overlineScaled(11)).tracking(1.0) @@ -973,9 +1043,7 @@ struct LiquidTodayView: View { } .padding(.horizontal, 14) .padding(.vertical, 11) - .background(NoopPanelSurface(tint: tint, cornerRadius: 20, surfaceOpacity: cardOpacity)) - } - .buttonStyle(LiquidPressStyle()) + .background(NoopPanelSurface(tint: tint, cornerRadius: 20, surfaceOpacity: cardOpacity)) } // MARK: - Synthesis (greeting + readiness pills + one-liner) @@ -984,7 +1052,7 @@ struct LiquidTodayView: View { /// card when today's Effort is ~0, so a calm day explains itself instead of a bare 0. Reuses classic's /// String Catalog entry verbatim — one key serves both Today screens. private var effortZeroNote: String? { - guard EffortDisplay.showsZeroNote(strain: displayDay?.strain, isToday: selectedDayOffset == 0) else { return nil } + guard EffortDisplay.showsZeroNote(strain: effortStrain(displayDay), isToday: selectedDayOffset == 0) else { return nil } return String(localized: "No cardio load yet. Effort builds once your heart rate climbs into your effort zone (around 50% of your heart-rate reserve). A calm day honestly reads near zero.") } @@ -1039,6 +1107,15 @@ struct LiquidTodayView: View { Text(chargeDisplay.calibrationDetail ?? synthLine) .font(StrandFont.body).foregroundStyle(StrandPalette.textPrimary) .fixedSize(horizontal: false, vertical: true) + // The reason the count is not moving, when nights are arriving empty. Sits under + // the progress rather than replacing it: the wearer needs both the number and why. + if let why = chargeDisplay.calibrationReason( + dayKeys: repo.days.map(\.day), nightlyHrv: repo.days.map(\.avgHrv), + today: Repository.logicalDayKey(Date())) { + Text(why).font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } // #530 follow-up: the classic hero's "no cardio load yet" note (effortZeroNote), // shown on a calm day so today's ~0 Effort explains itself instead of a bare 0. if let note = effortZeroNote { @@ -1069,9 +1146,10 @@ struct LiquidTodayView: View { private var recoveryVitalsSection: some View { // PER-FIELD, today-first carry: each vital reads today's own value, else falls back to the prior - // day that recorded it (`vitalsDay`). Coalesce ONCE so the number and its fill fraction agree. - let hrv = displayDay?.avgHrv ?? vitalsDay?.avgHrv - let rhr = (displayDay?.restingHr ?? vitalsDay?.restingHr).map(Double.init) + // day that recorded THAT vital (#1842 — `vitalsDay` is the freshest row with ANY of the three, so it + // blanks one the row lacks). Coalesce ONCE so the number and its fill fraction agree. + let hrv = displayDay?.avgHrv ?? hrvDay?.avgHrv + let rhr = (displayDay?.restingHr ?? restingHrDay?.restingHr).map(Double.init) let resp = displayDay?.respRateBpm ?? vitalsDay?.respRateBpm return card { VStack(alignment: .leading, spacing: 12) { @@ -1083,22 +1161,48 @@ struct LiquidTodayView: View { Text(line).font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) } } + // #706/#684: the same trends the HRV / Resting HR / Respiratory dashboard cards push. These + // rows show the SAME three vitals and had no way through, so this card was the one place on + // Today where a metric was a dead end. Routes taken from `liquidCard`'s own cases so the two + // surfaces cannot send the same vital to different trends. vitalRow(String(localized: "Heart-rate variability"), unitText(hrv, "ms"), - StrandPalette.metricCyan, fracOver(hrv, 120)) + StrandPalette.metricCyan, fracOver(hrv, 120), route: .metric("hrv")) vitalRow(String(localized: "Resting heart rate"), unitText(rhr, "bpm"), - StrandPalette.metricRose, fracOver(rhr, 100)) + StrandPalette.metricRose, fracOver(rhr, 100), route: .metric("rhr")) vitalRow(String(localized: "Breaths per minute"), unitText(resp, "rpm", decimals: 1), - StrandPalette.accent, fracOver(resp, 24)) + StrandPalette.accent, fracOver(resp, 24), route: .metric("resp_rate")) } } } - private func vitalRow(_ label: String, _ value: String, _ tint: Color, _ frac: Double?) -> some View { + /// A recovery-vital row, optionally pushing its own metric trend. + /// + /// `route: nil` renders exactly what shipped before - no link, no chevron - so a row that goes nowhere + /// never claims otherwise. `LiquidPressStyle` is not decoration: a bare `NavigationLink` applies the + /// default link chrome and would tint the whole row, which is the same reason `cardLink` carries it. + private func vitalRow(_ label: String, _ value: String, _ tint: Color, _ frac: Double?, + route: TabRoute? = nil) -> some View { + Group { + if let route { + NavigationLink(value: route) { vitalRowBody(label, value, tint, frac, linked: true) } + .buttonStyle(LiquidPressStyle()) + } else { + vitalRowBody(label, value, tint, frac, linked: false) + } + } + } + + private func vitalRowBody(_ label: String, _ value: String, _ tint: Color, _ frac: Double?, + linked: Bool) -> some View { HStack(spacing: 12) { LiquidVessel(value: frac, tint: tint, animated: false).frame(width: 26, height: 26) Text(label).font(StrandFont.subhead).foregroundStyle(StrandPalette.textSecondary) Spacer() Text(value).font(StrandFont.number(15)).foregroundStyle(StrandPalette.textPrimary) + if linked { + Image(systemName: "chevron.right").font(.system(size: 12, weight: .semibold)) + .foregroundStyle(StrandPalette.textTertiary) + } } } @@ -1133,8 +1237,8 @@ struct LiquidTodayView: View { // HRV / Rest HR (+ Blood Oxygen / Respiratory) tiles share the recovery vitals' per-field // today-first carry so they don't blank at the rollover while Recovery/Strain/Rest stay strictly // today's own (they are scored surfaces). - let hrv = displayDay?.avgHrv ?? vitalsDay?.avgHrv - let rhr = (displayDay?.restingHr ?? vitalsDay?.restingHr).map(Double.init) + let hrv = displayDay?.avgHrv ?? hrvDay?.avgHrv + let rhr = (displayDay?.restingHr ?? restingHrDay?.restingHr).map(Double.init) return VStack(spacing: 8) { HStack(alignment: .firstTextBaseline, spacing: 8) { sectionHead("KEY METRICS", trailing: trendWindowLabel) @@ -1183,7 +1287,12 @@ struct LiquidTodayView: View { // stays raw, matching the Effort hero, which correctly does not carry. ktile(String(localized: "Recovery"), icon: keyMetricIcon(metric), intText(chargeDisplay.pct), "%", StrandPalette.chargeColor, frac(chargeDisplay.pct), key: "recovery") case .effort: - ktile(String(localized: "Strain"), icon: keyMetricIcon(metric), intText(displayDay?.strain), "%", StrandPalette.effortColor, frac(displayDay?.strain), key: "strain") + // #492: Effort is a load index (0–100 NOOP / 0–21 WHOOP), NOT a percentage, and the unit was + // wrong on either axis. Fixed on Android and in `TodayView` at the time; THIS view kept the old + // form, so the tile also ignored the scale toggle — the hero ring above it read ~8 on the WHOOP + // axis while this read 38. `effortText` is the same shared formatter the ring and the workout + // rows use, so all three now agree by construction. + ktile(String(localized: "Strain"), icon: keyMetricIcon(metric), effortText(effortStrain(displayDay)), "", StrandPalette.effortColor, frac(effortStrain(displayDay)), key: "strain") case .rest: ktile(String(localized: "Rest"), icon: keyMetricIcon(metric), intText(restScore), "%", StrandPalette.restColor, frac(restScore), key: "sleep_performance") case .hrv: @@ -1218,6 +1327,19 @@ struct LiquidTodayView: View { // detail source, so the number, its sparkline and the chart it opens all agree. ktile(String(localized: "Calories"), icon: keyMetricIcon(metric), intText(caloriesCount), "kcal", StrandPalette.metricAmber, fracOver(caloriesCount, 800), key: "energy_kcal", detailMetric: caloriesDetailMetric) + case .skinTemp: + // Added 2026-08-24 (queue 11c follow-up): first Key Metrics appearance for Skin Temp — was + // already a "Your Cards" tile (`DashboardCard.skinTemp`), never a Key Metrics one. Same + // 2-level carry the Blood Oxygen case just above uses (displayDay → the cached vitals carry), + // and the SAME `SkinTempDisplay` formatter every other skin-temp surface uses so a deviation + // reads "+0.1 Δ°C" here exactly as it does on "Your Cards"/the Deep Timeline, never the plain + // `%+.1f°` that read a fabricated absolute value for a signed deviation (#622). + // #1844: same lead-with-the-absolute resolution as "Your Cards" above, so the two agree. + let skinText = TodayView.skinTempCardValue(reading: skinTempLeadReading, + fahrenheit: temperatureUnit == .fahrenheit) + // The card's own unit is deliberately empty — the value carries "°C"/"Δ°F" itself, same as + // the classic TodayView Skin Temp card. + ktile(String(localized: "Skin Temp"), icon: keyMetricIcon(metric), skinText, "", StrandPalette.metricAmber, nil, key: "skin_temp") } } @@ -1233,12 +1355,13 @@ struct LiquidTodayView: View { case .steps: return "figure.walk" case .weight: return "scalemass.fill" case .calories: return "flame.fill" + case .skinTemp: return "thermometer.medium" } } private func ktile(_ label: String, icon: String, _ value: String, _ unit: String, _ tint: Color, _ frac: Double?, key: String? = nil, detailMetric: MetricDescriptor? = nil, caption: String? = nil) -> some View { - let displayValue = unit.isEmpty ? value : (unit == "%" ? value + unit : value + " " + unit) + let displayValue = Self.tileDisplayValue(value, unit: unit) let tile = VStack(alignment: .leading, spacing: 10) { HStack(spacing: 6) { Image(systemName: icon) @@ -1410,6 +1533,9 @@ struct LiquidTodayView: View { let tkey = cachedDisplayDay?.day ?? selectedDayKey cachedVitalsDay = (selectedDayOffset == 0) ? Repository.lastVitalsDay(days: repo.days, todayKey: tkey) : nil cachedRespDay = (selectedDayOffset == 0) ? Repository.lastRespDay(days: repo.days, todayKey: tkey) : nil + cachedHrvDay = (selectedDayOffset == 0) ? Repository.lastHrvDay(days: repo.days, todayKey: tkey) : nil + cachedRestingHrDay = (selectedDayOffset == 0) ? Repository.lastRestingHrDay(days: repo.days, todayKey: tkey) : nil + cachedSkinTempReadingDay = (selectedDayOffset == 0) ? Repository.lastSkinTempReadingDay(days: repo.days, todayKey: tkey) : nil // Charge carry (#543) + the honest label, resolved here for the same reason as the two above: the // selector below scans repo.days. Calibration nights come from the SAME `RecoveryScorer` helper the // classic Today reads, so the two screens agree on when a wearer is genuinely mid-calibration @@ -1433,11 +1559,39 @@ struct LiquidTodayView: View { let cal = Calendar.current let dayStart = cal.startOfDay(for: selectedLogicalDay) - let from = Int(dayStart.timeIntervalSince1970) + let calendarFrom = Int(dayStart.timeIntervalSince1970) // today → midnight..now; a past day → its full 24h (a missing morning reads as empty space). - let to: Int = selectedDayOffset == 0 + let calendarTo: Int = selectedDayOffset == 0 ? Int(Date().timeIntervalSince1970) : Int((cal.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart).timeIntervalSince1970) + let nextDayKey = Repository.localDayKey(cal.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart) + let cycleMarkers = dayCycleMode == .sleepOnset + ? await repo.exploreSeries(key: DayCycleIntelligenceIntegration.onsetKey, source: "my-whoop") : [] + let from = cycleMarkers.last(where: { $0.day == selectedDayKey }).map { Int($0.value) } ?? calendarFrom + let toExclusive = cycleMarkers.last(where: { $0.day == nextDayKey }).map { Int($0.value) } ?? calendarTo + let to = max(from, toExclusive - 1) + // #1001: in-progress Effort for TODAY, over the SAME window resolved just above (the day-cycle + // onset when that mode is on, else calendar midnight → now) with the identical params the daily + // pass uses, so the live number matches what the engine will eventually persist. Below + // `StrainScorer.minReadings` the scorer returns nil and the read-outs fall back to the stored row + // — never a fabricated value. A navigated past day clears it. + let liveStrainLocal: Double? + if selectedDayOffset == 0 { + // An EXPLICIT limit, not the 8000 default: that default is chart-sized, and this read is + // whole-window. `hrSamples` is `ORDER BY ts ASC LIMIT`, so truncation drops the NEWEST rows — + // at the ~18k HR rows a real day banks, the default covered roughly the first ten hours and the + // live score silently stopped climbing after that. It failed safe (`effectiveEffort` takes the + // max, so the stored row simply won) which is why it went unnoticed. 200_000 is what every + // other whole-window HR consumer already passes. + let todayHr = await repo.hrSamples(from: from, to: to, limit: 200_000) + let maxHR = profile.age > 0 ? StrainScorer.tanakaHRmax(age: Double(profile.age)) : nil + let restHR = day?.restingHr.map(Double.init) ?? StrainScorer.defaultRestingHR + liveStrainLocal = StrainScorer.strain(todayHr, maxHR: maxHR, restingHR: restHR, + method: PuffinExperiment.effortMethod, sex: profile.sex) + } else { + liveStrainLocal = nil + } + liveTodayStrain = liveStrainLocal async let restA = repo.exploreSeries(key: "sleep_performance", source: "my-whoop") async let stressA = repo.series(key: "stress", source: "my-whoop") @@ -1511,6 +1665,9 @@ struct LiquidTodayView: View { "rhr": sparkRows.compactMap { r in r.restingHr.map { (r.day, Double($0)) } }, "spo2": sparkRows.compactMap { r in r.spo2Pct.map { (r.day, $0) } }, "spo2_candidate": spo2CandSeries.filter { $0.day >= sparkCutoff && $0.day <= selectedDayKey }, + // Added 2026-08-24 (queue 11c follow-up) for the new Skin Temp Key Metrics tile — already + // loaded on `sparkRows` (`daysSnapshot`), same as every other DailyMetric-column tile above. + "skin_temp": sparkRows.compactMap { r in r.skinTempDevC.map { (r.day, $0) } }, "resp_rate": sparkRows.compactMap { r in r.respRateBpm.map { (r.day, $0) } }, "steps": sparkRows.compactMap { r in r.steps.map { (r.day, Double($0)) } }, // #616: the Calories tile drew no trend line — this dict had no matching entry, so windowedSpark @@ -1541,7 +1698,7 @@ struct LiquidTodayView: View { // HR estimate for the day, so the tile/card/detail agree (imported-first, mirrors steps). importedActiveKcalDay = (await appleA).filter { $0.day == selectedDayKey }.compactMap { $0.activeKcal }.max() hrValues = (await hrA).map { $0.bpm } - workouts = await wkA + workouts = (await wkA).filter { $0.startTs >= from && $0.startTs < to } let (chargeSource, effortSource, restSource) = await (chargeSourceA, effortSourceA, restSourceA) let sourceResolutions = [ @@ -1575,7 +1732,7 @@ struct LiquidTodayView: View { if HostedCardPrefs.decodeEnabled(hostedCardsRaw).contains(where: { $0.origin == sleepOrigin }) { let hostedSessions = await repo.allSleepSessions() let hostedHabitual = await repo.habitualMidsleepSec() - let hostedMotion = await repo.sessionMotions(starts: hostedSessions.map { $0.startTs }) + let hostedMotion = await repo.sessionMotions(sessions: hostedSessions) hostedSleepModel = SleepModel.build(SleepModelInputs( days: repo.days, sleeps: repo.sleeps, @@ -1697,10 +1854,26 @@ struct LiquidTodayView: View { private func frac(_ v: Double?) -> Double? { v.map { max(0, min(1, $0 / 100)) } } private func fracOver(_ v: Double?, _ over: Double) -> Double? { v.map { max(0, min(1, $0 / over)) } } - private func intText(_ v: Double?) -> String { v.map { String(Int($0.rounded())) } ?? "–" } + + /// What a tile shows when the metric has no value. One constant rather than a dash repeated at each + /// site, because the unit-suppression below has to recognise it. + static let noValueDash = "–" + + /// Join a formatted tile value with its unit, dropping the unit when there is no value. + /// + /// #492 and the `–%` it left behind: a missing metric rendered its placeholder AND its unit, so an + /// empty Strain tile read `–%`, which parses as "minus percent" rather than "no data". Android has + /// had the guard all along (`unit = if (restScore != null) "%" else ""`, and `withUnit`'s NO_DATA + /// check); this is the iOS twin of it. `%` binds tight, every other unit takes a space. + static func tileDisplayValue(_ value: String, unit: String) -> String { + guard !unit.isEmpty, value != noValueDash else { return value } + return unit == "%" ? value + unit : value + " " + unit + } + + private func intText(_ v: Double?) -> String { v.map { String(Int($0.rounded())) } ?? Self.noValueDash } private func unitText(_ v: Double?, _ unit: String, decimals: Int = 0) -> String { - guard let v else { return "–" } + guard let v else { return Self.noValueDash } let n = decimals > 0 ? String(format: "%.\(decimals)f", locale: AppLanguage.activeLocale, v) : String(Int(v.rounded())) return unit.isEmpty ? n : "\(n) \(unit)" } @@ -1726,7 +1899,12 @@ struct LiquidTodayView: View { // even once it had a value to show (#1627). @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } + @AppStorage(UnitPrefs.distanceSystemKey) private var distanceSystemRaw = "" + private var distanceUnitSystem: UnitSystem { + UnitPrefs.resolveDistance(system: unitSystem, override: distanceSystemRaw) + } @AppStorage(UnitPrefs.temperatureKey) private var temperatureRaw = "" + @AppStorage(UnitPrefs.skinTempDisplayKey) private var skinTempDisplayRaw = "" // #1846 private var temperatureUnit: TemperatureUnit { UnitPrefs.resolveTemperature(system: unitSystem, override: temperatureRaw) } @@ -1736,8 +1914,17 @@ struct LiquidTodayView: View { @AppStorage(UnitPrefs.effortScaleKey) private var effortScaleRaw = EffortScale.hundred.rawValue private var effortScale: EffortScale { UnitPrefs.resolveEffortScale(effortScaleRaw) } + /// The Effort this view should show: the live in-progress score when it beats the stored row, else the + /// row (#1001). `StrainScorer.effectiveEffort` holds the never-drop floor and the live/stored + /// preference and is shared with the Kotlin twin, so the two platforms cannot resolve Effort + /// differently. `d` for today is always today's row or nil, never a prior day, so the floor cannot + /// resurrect a stale day — it only stops a read-out dropping below what today has already earned. + private func effortStrain(_ d: DailyMetric?) -> Double? { + StrainScorer.effectiveEffort(live: selectedDayOffset == 0 ? liveTodayStrain : nil, stored: d?.strain) + } + private func effortText(_ s: Double?) -> String { - guard let s else { return "–" } + guard let s else { return Self.noValueDash } // Route through the shared formatter instead of hardcoding *21: a default (0–100) user was shown the // WHOOP-scaled number here while the hero + Workouts table showed 0–100, two numbers for one workout. return UnitFormatter.effortDisplay(s, scale: effortScale) @@ -1747,7 +1934,9 @@ struct LiquidTodayView: View { var parts: [String] = [] let secs = w.durationS ?? Double(max(w.endTs - w.startTs, 0)) parts.append("\(Int(secs / 60)) min") - if let dm = w.distanceM, dm > 0 { parts.append(String(format: "%.1f km", locale: AppLanguage.activeLocale, dm / 1000)) } + if let dm = w.distanceM, dm > 0 { + parts.append(UnitFormatter.distanceFromMeters(dm, system: distanceUnitSystem)) + } if let k = w.energyKcal { parts.append("\(Int(k.rounded())) kcal") } return parts.joined(separator: " · ") } @@ -1767,11 +1956,15 @@ struct LiquidTodayView: View { /// "Latest sleep · " (#779) instead of a false "Last night". When every shown vital is today's /// own (or there's nothing to carry), it returns nil — the card must not claim "Last night" at all. private var vitalsProvenanceLine: String? { - guard let carried = vitalsDay else { return nil } - let carriedHrv = displayDay?.avgHrv == nil && carried.avgHrv != nil - let carriedRhr = displayDay?.restingHr == nil && carried.restingHr != nil - let carriedResp = displayDay?.respRateBpm == nil && carried.respRateBpm != nil - guard carriedHrv || carriedRhr || carriedResp else { return nil } + // Each vital can carry from a DIFFERENT row (#1842), so the one card-level footnote stamps the + // OLDEST row any SHOWN carried vital came from — erring old is the only safe direction for a caption + // whose job is to stop a stale read passing as today's, and it keeps the "Latest sleep" relabel + // (#779) firing on the value that actually is weeks old. A row counts only if it SUPPLIED the value. + let fromHrv: DailyMetric? = (displayDay?.avgHrv == nil && hrvDay?.avgHrv != nil) ? hrvDay : nil + let fromRhr: DailyMetric? = (displayDay?.restingHr == nil && restingHrDay?.restingHr != nil) ? restingHrDay : nil + let fromResp: DailyMetric? = (displayDay?.respRateBpm == nil && vitalsDay?.respRateBpm != nil) ? vitalsDay : nil + let sources: [DailyMetric] = [fromHrv, fromRhr, fromResp].compactMap { $0 } + guard let carried = sources.min(by: { $0.day < $1.day }) else { return nil } return TodayView.carriedCaption(priorDayKey: carried.day, todayKey: displayDay?.day ?? selectedDayKey) } @@ -2210,9 +2403,9 @@ extension LiquidTodayView { /// The Effort hero's "no cardio load yet" honest note (#530 follow-up — Liquid parity with classic /// `TodayView.effortZeroNote`). Pure + static so the gate is testable with no view: the note shows /// ONLY for today when a strain value exists and is ~0 — a genuinely calm day reads near zero, while a - /// no-data day shows its own ring overlay and a past day is never annotated. Liquid reads - /// `displayDay?.strain` directly (it has no live-strain accumulator like classic's `liveTodayStrain`), - /// which is exactly the value its Effort hero draws. + /// no-data day shows its own ring overlay and a past day is never annotated. The caller passes + /// `effortStrain(displayDay)` — the SAME resolved value the Effort hero draws (#1001) — so the note + /// and the hero can never disagree about whether today is at zero. enum EffortDisplay { static func showsZeroNote(strain: Double?, isToday: Bool) -> Bool { guard isToday, let s = strain else { return false } @@ -2293,6 +2486,15 @@ extension LiquidTodayView { return String(localized: "Learning your baseline, \(nights) of \(Baselines.minNightsSeed) nights.") } + /// The reason half, when nights are arriving but most carry no HRV (see `TodayView`'s twin). Nil + /// when every observed night counted, so a healthy calibration says nothing extra. + func calibrationReason(dayKeys: [String], nightlyHrv: [Double?], today: String) -> String? { + guard case .calibrating = self else { return nil } + let cov = Baselines.recentHrvCoverage(dayKeys: dayKeys, nightlyHrv: nightlyHrv, today: today) + guard cov.missing > 0, cov.observed > 0 else { return nil } + return String(localized: "\(cov.missing) of the last \(cov.observed) nights recorded no HRV. Check the strap is worn overnight and syncing.") + } + static func resolve(todayRecovery: Double?, priorScored: DailyMetric?, calibrationNights: Int?, todayKey: String) -> ChargeDisplay { if let pct = todayRecovery { return .scored(pct: pct) } diff --git a/Strand/Onboarding/OnboardingWizard.swift b/Strand/Onboarding/OnboardingWizard.swift index b7db8cb72f..e91d24f4af 100644 --- a/Strand/Onboarding/OnboardingWizard.swift +++ b/Strand/Onboarding/OnboardingWizard.swift @@ -727,10 +727,17 @@ private struct BondedStep: View { private struct ProfileStep: View { @EnvironmentObject private var profile: ProfileStore - // Imperial/Metric display preference (D#103). The stored profile is always SI; the steppers keep - // operating in SI (0.5 kg / 1 cm) and only the DISPLAYED value re-labels to lb / ft-in. + // The stored profile is always SI. Body measurements and exercise distance can follow the regional + // conventions independently; an unset distance choice follows the body choice for compatibility. @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue + @AppStorage(UnitPrefs.distanceSystemKey) private var distanceSystemRaw = "" private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } + private var distanceUnitSystem: UnitSystem { + UnitPrefs.resolveDistance(system: unitSystem, override: distanceSystemRaw) + } + private var distanceSystemBinding: Binding { + Binding(get: { distanceUnitSystem.rawValue }, set: { distanceSystemRaw = $0 }) + } private let sexes: [(String, String)] = [ ("male", String(localized: "Male")), ("female", String(localized: "Female")), @@ -767,14 +774,11 @@ private struct ProfileStep: View { Divider().overlay(StrandPalette.hairline) - // Units control (#781). Without this, onboarding read `unitSystemRaw` for the - // Weight/Height display but had NO way to set it, so US users were locked to - // kg/cm until they later found Settings → Units. Mirror the Sex picker idiom; the - // stored profile stays SI either way, only the displayed labels re-format (lb / ft-in - // via UnitFormatter). Same key (`units.system`) the Settings → Units card writes. + // Keep the two choices explicit here: "Metric/Imperial" alone cannot describe + // common mixed conventions such as Canadian pounds with kilometres. VStack(alignment: .leading, spacing: 8) { - Text("Units").strandOverline() - Picker("Units", selection: $unitSystemRaw) { + Text("Body measurements").strandOverline() + Picker("Body measurements", selection: $unitSystemRaw) { Text("Metric").tag(UnitSystem.metric.rawValue) Text("Imperial").tag(UnitSystem.imperial.rawValue) } @@ -784,6 +788,18 @@ private struct ProfileStep: View { Divider().overlay(StrandPalette.hairline) + VStack(alignment: .leading, spacing: 8) { + Text("Exercise distance & pace").strandOverline() + Picker("Exercise distance & pace", selection: distanceSystemBinding) { + Text("Kilometres").tag(UnitSystem.metric.rawValue) + Text("Miles").tag(UnitSystem.imperial.rawValue) + } + .pickerStyle(.segmented) + .labelsHidden() + } + + Divider().overlay(StrandPalette.hairline) + // Steppers, not sliders — matches the Age row above and the macOS Settings // profile editor (same ranges/steps), so every numeric profile field is // consistent across onboarding and Settings on both platforms. diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index ac5eaae10c..0844b4a27e 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1,14 +1,80 @@ { "sourceLanguage": "en", "strings": { + "Heart rate, no reading": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Herzfrequenz, keine Messung" } }, "en": { "stringUnit": { "state": "translated", "value": "Heart rate, no reading" } }, "es": { "stringUnit": { "state": "translated", "value": "Frecuencia cardíaca, sin lectura" } }, "fr": { "stringUnit": { "state": "translated", "value": "Fréquence cardiaque, aucune mesure" } }, "it": { "stringUnit": { "state": "translated", "value": "Frequenza cardiaca, nessuna lettura" } }, "pl": { "stringUnit": { "state": "translated", "value": "Tętno, brak odczytu" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Frequência cardíaca, sem leitura" } }, "ru": { "stringUnit": { "state": "translated", "value": "Пульс, нет данных" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "心率,无读数" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "心率,無讀數" } } + } }, + "Heart rate %lld bpm": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Herzfrequenz %lld bpm" } }, "en": { "stringUnit": { "state": "translated", "value": "Heart rate %lld bpm" } }, "es": { "stringUnit": { "state": "translated", "value": "Frecuencia cardíaca %lld ppm" } }, "fr": { "stringUnit": { "state": "translated", "value": "Fréquence cardiaque %lld bpm" } }, "it": { "stringUnit": { "state": "translated", "value": "Frequenza cardiaca %lld bpm" } }, "pl": { "stringUnit": { "state": "translated", "value": "Tętno %lld bpm" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Frequência cardíaca %lld bpm" } }, "ru": { "stringUnit": { "state": "translated", "value": "Пульс %lld уд/мин" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "心率 %lld bpm" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "心率 %lld bpm" } } + } }, + "Heart rate %lld bpm, minimum %lld, maximum %lld": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Herzfrequenz %lld bpm, Minimum %lld, Maximum %lld" } }, "en": { "stringUnit": { "state": "translated", "value": "Heart rate %lld bpm, minimum %lld, maximum %lld" } }, "es": { "stringUnit": { "state": "translated", "value": "Frecuencia cardíaca %lld ppm, mínimo %lld, máximo %lld" } }, "fr": { "stringUnit": { "state": "translated", "value": "Fréquence cardiaque %lld bpm, minimum %lld, maximum %lld" } }, "it": { "stringUnit": { "state": "translated", "value": "Frequenza cardiaca %lld bpm, minimo %lld, massimo %lld" } }, "pl": { "stringUnit": { "state": "translated", "value": "Tętno %lld bpm, minimum %lld, maksimum %lld" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Frequência cardíaca %lld bpm, mínimo %lld, máximo %lld" } }, "ru": { "stringUnit": { "state": "translated", "value": "Пульс %lld уд/мин, минимум %lld, максимум %lld" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "心率 %lld bpm,最低 %lld,最高 %lld" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "心率 %lld bpm,最低 %lld,最高 %lld" } } + } }, + "Min %lld • Max %lld": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Min. %lld • Max. %lld" } }, "en": { "stringUnit": { "state": "translated", "value": "Min %lld • Max %lld" } }, "es": { "stringUnit": { "state": "translated", "value": "Mín %lld • Máx %lld" } }, "fr": { "stringUnit": { "state": "translated", "value": "Min. %lld • Max. %lld" } }, "it": { "stringUnit": { "state": "translated", "value": "Min. %lld • Max. %lld" } }, "pl": { "stringUnit": { "state": "translated", "value": "Min %lld • Maks %lld" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Mín %lld • Máx %lld" } }, "ru": { "stringUnit": { "state": "translated", "value": "Мин %lld • Макс %lld" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "最低 %lld • 最高 %lld" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "最低 %lld • 最高 %lld" } } + } }, + "Updated %@": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Aktualisiert %@" } }, "en": { "stringUnit": { "state": "translated", "value": "Updated %@" } }, "es": { "stringUnit": { "state": "translated", "value": "Actualizado %@" } }, "fr": { "stringUnit": { "state": "translated", "value": "Mis à jour %@" } }, "it": { "stringUnit": { "state": "translated", "value": "Aggiornato %@" } }, "pl": { "stringUnit": { "state": "translated", "value": "Zaktualizowano %@" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Atualizado %@" } }, "ru": { "stringUnit": { "state": "translated", "value": "Обновлено %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "更新于 %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "更新於 %@" } } + } }, + "Body measurement units": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Einheiten für Körpermaße" } }, "es": { "stringUnit": { "state": "translated", "value": "Unidades de medidas corporales" } }, "fr": { "stringUnit": { "state": "translated", "value": "Unités des mensurations" } }, "it": { "stringUnit": { "state": "translated", "value": "Unità delle misure corporee" } }, "pl": { "stringUnit": { "state": "translated", "value": "Jednostki pomiarów ciała" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Unidades das medidas corporais" } }, "ru": { "stringUnit": { "state": "translated", "value": "Единицы параметров тела" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "身体测量单位" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "身體測量單位" } } + } }, + "Body measurements": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Körpermaße" } }, "es": { "stringUnit": { "state": "translated", "value": "Medidas corporales" } }, "fr": { "stringUnit": { "state": "translated", "value": "Mensurations" } }, "it": { "stringUnit": { "state": "translated", "value": "Misure corporee" } }, "pl": { "stringUnit": { "state": "translated", "value": "Pomiary ciała" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Medidas corporais" } }, "ru": { "stringUnit": { "state": "translated", "value": "Параметры тела" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "身体测量" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "身體測量" } } + } }, + "Choose body measurements and exercise distance separately. Your data is always stored the same way; these settings only change its display.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Wähle Einheiten für Körpermaße und Trainingsdistanz separat. Deine Daten werden immer gleich gespeichert; diese Einstellungen ändern nur die Anzeige." } }, "es": { "stringUnit": { "state": "translated", "value": "Elige por separado las unidades de las medidas corporales y de la distancia de ejercicio. Los datos siempre se almacenan igual; estos ajustes solo cambian su visualización." } }, "fr": { "stringUnit": { "state": "translated", "value": "Choisissez séparément les unités des mensurations et de la distance d’exercice. Les données sont toujours stockées de la même façon ; ces réglages ne changent que leur affichage." } }, "it": { "stringUnit": { "state": "translated", "value": "Scegli separatamente le unità per le misure corporee e la distanza di allenamento. I dati vengono sempre memorizzati allo stesso modo; queste impostazioni cambiano solo la visualizzazione." } }, "pl": { "stringUnit": { "state": "translated", "value": "Wybierz osobno jednostki pomiarów ciała i dystansu treningu. Dane są zawsze przechowywane tak samo; te ustawienia zmieniają tylko sposób wyświetlania." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Escolha separadamente as unidades para medidas corporais e distância de exercício. Os dados são sempre armazenados da mesma forma; estas definições alteram apenas a apresentação." } }, "ru": { "stringUnit": { "state": "translated", "value": "Выберите единицы для параметров тела и дистанции тренировки отдельно. Данные всегда хранятся одинаково; эти настройки меняют только отображение." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "分别选择身体测量和运动距离的单位。数据的存储方式始终不变;这些设置只会改变显示方式。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "分別選擇身體測量和運動距離的單位。資料的儲存方式始終不變;這些設定只會改變顯示方式。" } } + } }, + "Distance must be 0–621 mi.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Die Distanz muss zwischen 0 und 621 mi liegen." } }, "es": { "stringUnit": { "state": "translated", "value": "La distancia debe ser de 0 a 621 mi." } }, "fr": { "stringUnit": { "state": "translated", "value": "La distance doit être comprise entre 0 et 621 mi." } }, "it": { "stringUnit": { "state": "translated", "value": "La distanza deve essere compresa tra 0 e 621 mi." } }, "pl": { "stringUnit": { "state": "translated", "value": "Dystans musi wynosić od 0 do 621 mi." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A distância deve ser entre 0 e 621 mi." } }, "ru": { "stringUnit": { "state": "translated", "value": "Дистанция должна быть от 0 до 621 мили." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "距离必须在 0–621 英里之间。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "距離必須在 0–621 英里之間。" } } + } }, + "Exercise distance & pace": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Trainingsdistanz & Tempo" } }, "es": { "stringUnit": { "state": "translated", "value": "Distancia y ritmo del ejercicio" } }, "fr": { "stringUnit": { "state": "translated", "value": "Distance et allure d’exercice" } }, "it": { "stringUnit": { "state": "translated", "value": "Distanza e passo dell’allenamento" } }, "pl": { "stringUnit": { "state": "translated", "value": "Dystans i tempo treningu" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Distância e ritmo do exercício" } }, "ru": { "stringUnit": { "state": "translated", "value": "Дистанция и темп тренировки" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "运动距离和配速" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "運動距離和配速" } } + } }, + "Exercise distance and pace units": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Einheiten für Trainingsdistanz und Tempo" } }, "es": { "stringUnit": { "state": "translated", "value": "Unidades de distancia y ritmo del ejercicio" } }, "fr": { "stringUnit": { "state": "translated", "value": "Unités de distance et d’allure d’exercice" } }, "it": { "stringUnit": { "state": "translated", "value": "Unità di distanza e passo dell’allenamento" } }, "pl": { "stringUnit": { "state": "translated", "value": "Jednostki dystansu i tempa treningu" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Unidades de distância e ritmo do exercício" } }, "ru": { "stringUnit": { "state": "translated", "value": "Единицы дистанции и темпа тренировки" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "运动距离和配速单位" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "運動距離和配速單位" } } + } }, + "Follow body": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Wie Körpermaße" } }, "es": { "stringUnit": { "state": "translated", "value": "Según medidas corporales" } }, "fr": { "stringUnit": { "state": "translated", "value": "Selon les mensurations" } }, "it": { "stringUnit": { "state": "translated", "value": "Come misure corporee" } }, "pl": { "stringUnit": { "state": "translated", "value": "Jak pomiary ciała" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Como medidas corporais" } }, "ru": { "stringUnit": { "state": "translated", "value": "Как для параметров тела" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "跟随身体测量" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "跟隨身體測量" } } + } }, + "Kilometres": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Kilometer" } }, "es": { "stringUnit": { "state": "translated", "value": "Kilómetros" } }, "fr": { "stringUnit": { "state": "translated", "value": "Kilomètres" } }, "it": { "stringUnit": { "state": "translated", "value": "Chilometri" } }, "pl": { "stringUnit": { "state": "translated", "value": "Kilometry" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Quilómetros" } }, "ru": { "stringUnit": { "state": "translated", "value": "Километры" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "公里" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "公里" } } + } }, + "Miles": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Meilen" } }, "es": { "stringUnit": { "state": "translated", "value": "Millas" } }, "fr": { "stringUnit": { "state": "translated", "value": "Miles" } }, "it": { "stringUnit": { "state": "translated", "value": "Miglia" } }, "pl": { "stringUnit": { "state": "translated", "value": "Mile" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Milhas" } }, "ru": { "stringUnit": { "state": "translated", "value": "Мили" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "英里" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "英里" } } + } }, + "Calculating last night's sleep…": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Der Schlaf der letzten Nacht wird berechnet…" } }, "en": { "stringUnit": { "state": "translated", "value": "Calculating last night's sleep…" } }, "es": { "stringUnit": { "state": "translated", "value": "Calculando el sueño de anoche…" } }, "fr": { "stringUnit": { "state": "translated", "value": "Calcul du sommeil de la nuit dernière…" } }, "it": { "stringUnit": { "state": "translated", "value": "Calcolo del sonno della scorsa notte…" } }, "pl": { "stringUnit": { "state": "translated", "value": "Obliczanie snu z ostatniej nocy…" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A calcular o sono da noite passada…" } }, "ru": { "stringUnit": { "state": "translated", "value": "Рассчитываю сон за прошлую ночь…" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "正在计算昨夜睡眠…" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "正在計算昨夜睡眠…" } } + } }, + "Your strap history is in. NOOP is detecting and staging the night now.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Dein Strap-Verlauf ist da. NOOP erkennt und analysiert die Nacht jetzt." } }, "en": { "stringUnit": { "state": "translated", "value": "Your strap history is in. NOOP is detecting and staging the night now." } }, "es": { "stringUnit": { "state": "translated", "value": "El historial de tu pulsera ya está disponible. NOOP está detectando y analizando la noche." } }, "fr": { "stringUnit": { "state": "translated", "value": "L’historique de votre bracelet est arrivé. NOOP détecte et analyse maintenant la nuit." } }, "it": { "stringUnit": { "state": "translated", "value": "La cronologia della fascia è arrivata. NOOP sta rilevando e analizzando la notte." } }, "pl": { "stringUnit": { "state": "translated", "value": "Historia z opaski jest już dostępna. NOOP wykrywa i analizuje teraz noc." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "O histórico da bracelete chegou. A NOOP está agora a detetar e analisar a noite." } }, "ru": { "stringUnit": { "state": "translated", "value": "История с браслета загружена. NOOP сейчас определяет и анализирует ночной сон." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "手环历史数据已到达。NOOP 正在识别并分析这一夜的睡眠。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "手環歷史資料已到達。NOOP 正在識別並分析這一夜的睡眠。" } } + } }, + "Last night's sleep hasn't synced": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Der Schlaf der letzten Nacht wurde nicht synchronisiert" } }, "en": { "stringUnit": { "state": "translated", "value": "Last night's sleep hasn't synced" } }, "es": { "stringUnit": { "state": "translated", "value": "El sueño de anoche no se ha sincronizado" } }, "fr": { "stringUnit": { "state": "translated", "value": "Le sommeil de la nuit dernière n’a pas été synchronisé" } }, "it": { "stringUnit": { "state": "translated", "value": "Il sonno della scorsa notte non è stato sincronizzato" } }, "pl": { "stringUnit": { "state": "translated", "value": "Sen z ostatniej nocy nie został zsynchronizowany" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "O sono da noite passada não foi sincronizado" } }, "ru": { "stringUnit": { "state": "translated", "value": "Сон за прошлую ночь не синхронизирован" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "昨夜睡眠尚未同步" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "昨夜睡眠尚未同步" } } + } }, + "The history sync stopped before it finished. Keep the strap nearby and try Sync again.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Die Verlaufssynchronisierung wurde vorzeitig beendet. Halte den Strap in der Nähe und versuche die Synchronisierung erneut." } }, "en": { "stringUnit": { "state": "translated", "value": "The history sync stopped before it finished. Keep the strap nearby and try Sync again." } }, "es": { "stringUnit": { "state": "translated", "value": "La sincronización del historial se detuvo antes de terminar. Mantén la pulsera cerca e inténtalo de nuevo." } }, "fr": { "stringUnit": { "state": "translated", "value": "La synchronisation de l’historique s’est arrêtée avant la fin. Gardez le bracelet à proximité et réessayez." } }, "it": { "stringUnit": { "state": "translated", "value": "La sincronizzazione della cronologia si è interrotta prima di terminare. Tieni la fascia vicina e riprova." } }, "pl": { "stringUnit": { "state": "translated", "value": "Synchronizacja historii zatrzymała się przed ukończeniem. Trzymaj opaskę w pobliżu i spróbuj ponownie." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A sincronização do histórico parou antes de terminar. Mantém a bracelete por perto e tenta novamente." } }, "ru": { "stringUnit": { "state": "translated", "value": "Синхронизация истории остановилась до завершения. Держите браслет рядом и повторите попытку." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "历史同步未完成就停止了。请将手环放在附近并再次尝试同步。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "歷史同步未完成就停止了。請將手環放在附近並再次嘗試同步。" } } + } }, + "Waiting for last night's sleep": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Warten auf den Schlaf der letzten Nacht" } }, "en": { "stringUnit": { "state": "translated", "value": "Waiting for last night's sleep" } }, "es": { "stringUnit": { "state": "translated", "value": "Esperando el sueño de anoche" } }, "fr": { "stringUnit": { "state": "translated", "value": "En attente du sommeil de la nuit dernière" } }, "it": { "stringUnit": { "state": "translated", "value": "In attesa del sonno della scorsa notte" } }, "pl": { "stringUnit": { "state": "translated", "value": "Oczekiwanie na sen z ostatniej nocy" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "À espera do sono da noite passada" } }, "ru": { "stringUnit": { "state": "translated", "value": "Ожидание сна за прошлую ночь" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "正在等待昨夜睡眠" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "正在等待昨夜睡眠" } } + } }, + "Connect the strap and sync its history. NOOP will calculate the night when the overnight data arrives.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Verbinde den Strap und synchronisiere seinen Verlauf. NOOP berechnet die Nacht, sobald die Nachtdaten eintreffen." } }, "en": { "stringUnit": { "state": "translated", "value": "Connect the strap and sync its history. NOOP will calculate the night when the overnight data arrives." } }, "es": { "stringUnit": { "state": "translated", "value": "Conecta la pulsera y sincroniza su historial. NOOP calculará la noche cuando lleguen los datos nocturnos." } }, "fr": { "stringUnit": { "state": "translated", "value": "Connectez le bracelet et synchronisez son historique. NOOP calculera la nuit lorsque les données nocturnes arriveront." } }, "it": { "stringUnit": { "state": "translated", "value": "Collega la fascia e sincronizza la cronologia. NOOP calcolerà la notte quando arriveranno i dati notturni." } }, "pl": { "stringUnit": { "state": "translated", "value": "Połącz opaskę i zsynchronizuj jej historię. NOOP obliczy noc po otrzymaniu danych nocnych." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Liga a bracelete e sincroniza o histórico. A NOOP calculará a noite quando os dados noturnos chegarem." } }, "ru": { "stringUnit": { "state": "translated", "value": "Подключите браслет и синхронизируйте историю. NOOP рассчитает ночь после получения ночных данных." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "连接手环并同步历史。收到夜间数据后,NOOP 会计算这一夜的睡眠。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "連接手環並同步歷史。收到夜間資料後,NOOP 會計算這一夜的睡眠。" } } + } }, + "Last night's sleep wasn't detected": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Der Schlaf der letzten Nacht wurde nicht erkannt" } }, "en": { "stringUnit": { "state": "translated", "value": "Last night's sleep wasn't detected" } }, "es": { "stringUnit": { "state": "translated", "value": "No se detectó el sueño de anoche" } }, "fr": { "stringUnit": { "state": "translated", "value": "Le sommeil de la nuit dernière n’a pas été détecté" } }, "it": { "stringUnit": { "state": "translated", "value": "Il sonno della scorsa notte non è stato rilevato" } }, "pl": { "stringUnit": { "state": "translated", "value": "Nie wykryto snu z ostatniej nocy" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "O sono da noite passada não foi detetado" } }, "ru": { "stringUnit": { "state": "translated", "value": "Сон за прошлую ночь не обнаружен" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "未检测到昨夜睡眠" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "未偵測到昨夜睡眠" } } + } }, + "Sync finished, but NOOP couldn't confidently identify a sleep window. Keep the strap connected and try Sync again; the older night below is still your latest detected sleep.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Die Synchronisierung ist abgeschlossen, aber NOOP konnte kein Schlafzeitfenster sicher erkennen. Lass den Strap verbunden und synchronisiere erneut; die ältere Nacht unten ist weiterhin dein zuletzt erkannter Schlaf." } }, "en": { "stringUnit": { "state": "translated", "value": "Sync finished, but NOOP couldn't confidently identify a sleep window. Keep the strap connected and try Sync again; the older night below is still your latest detected sleep." } }, "es": { "stringUnit": { "state": "translated", "value": "La sincronización terminó, pero NOOP no pudo identificar con confianza un periodo de sueño. Mantén la pulsera conectada y vuelve a sincronizar; la noche anterior que aparece abajo sigue siendo tu último sueño detectado." } }, "fr": { "stringUnit": { "state": "translated", "value": "La synchronisation est terminée, mais NOOP n’a pas pu identifier avec certitude une période de sommeil. Gardez le bracelet connecté et relancez la synchronisation ; la nuit plus ancienne ci-dessous reste votre dernier sommeil détecté." } }, "it": { "stringUnit": { "state": "translated", "value": "La sincronizzazione è terminata, ma NOOP non ha identificato con certezza un intervallo di sonno. Tieni la fascia collegata e sincronizza di nuovo; la notte precedente qui sotto resta l’ultimo sonno rilevato." } }, "pl": { "stringUnit": { "state": "translated", "value": "Synchronizacja zakończyła się, ale NOOP nie zdołał wiarygodnie określić okna snu. Pozostaw opaskę połączoną i zsynchronizuj ponownie; starsza noc poniżej nadal jest ostatnim wykrytym snem." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A sincronização terminou, mas a NOOP não conseguiu identificar com confiança um período de sono. Mantém a bracelete ligada e sincroniza novamente; a noite anterior abaixo continua a ser o teu último sono detetado." } }, "ru": { "stringUnit": { "state": "translated", "value": "Синхронизация завершена, но NOOP не смог уверенно определить окно сна. Оставьте браслет подключённым и повторите синхронизацию; более ранняя ночь ниже остаётся последним обнаруженным сном." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "同步已完成,但 NOOP 无法可靠识别睡眠时段。请保持手环连接并再次同步;下方较早的一夜仍是最近一次检测到的睡眠。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "同步已完成,但 NOOP 無法可靠識別睡眠時段。請保持手環連接並再次同步;下方較早的一夜仍是最近一次偵測到的睡眠。" } } + } }, "RHR +%lld": { "localizations": { "de": { "stringUnit": { "state": "translated", "value": "Ruhepuls +%lld" } }, "en": { "stringUnit": { "state": "translated", "value": "RHR +%lld" } }, "es": { "stringUnit": { "state": "translated", "value": "FC en reposo +%lld" } }, "fr": { "stringUnit": { "state": "translated", "value": "FC au repos +%lld" } }, "it": { "stringUnit": { "state": "translated", "value": "FC a riposo +%lld" } }, "pl": { "stringUnit": { "state": "translated", "value": "Tętno spocz. +%lld" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "FC em repouso +%lld" } }, "ru": { "stringUnit": { "state": "translated", "value": "Пульс в покое +%lld" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "静息心率 +%lld" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "靜息心率 +%lld" } } } }, "HRV −%lld%%": { "localizations": { "de": { "stringUnit": { "state": "translated", "value": "HRV −%lld%%" } }, "en": { "stringUnit": { "state": "translated", "value": "HRV −%lld%%" } }, "es": { "stringUnit": { "state": "translated", "value": "VFC −%lld%%" } }, "fr": { "stringUnit": { "state": "translated", "value": "VFC −%lld%%" } }, "it": { "stringUnit": { "state": "translated", "value": "HRV −%lld%%" } }, "pl": { "stringUnit": { "state": "translated", "value": "HRV −%lld%%" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "VFC −%lld%%" } }, "ru": { "stringUnit": { "state": "translated", "value": "ВСР −%lld%%" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "HRV −%lld%%" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "HRV −%lld%%" } } } }, - "Skin temperature +%@ °C": { "localizations": { - "de": { "stringUnit": { "state": "translated", "value": "Hauttemperatur +%@ °C" } }, "en": { "stringUnit": { "state": "translated", "value": "Skin temperature +%@ °C" } }, "es": { "stringUnit": { "state": "translated", "value": "Temperatura de la piel +%@ °C" } }, "fr": { "stringUnit": { "state": "translated", "value": "Température cutanée +%@ °C" } }, "it": { "stringUnit": { "state": "translated", "value": "Temperatura cutanea +%@ °C" } }, "pl": { "stringUnit": { "state": "translated", "value": "Temperatura skóry +%@ °C" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Temperatura da pele +%@ °C" } }, "ru": { "stringUnit": { "state": "translated", "value": "Температура кожи +%@ °C" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "皮肤温度 +%@ °C" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "皮膚溫度 +%@ °C" } } + "Skin temperature %@": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Hauttemperatur %@" } }, "en": { "stringUnit": { "state": "translated", "value": "Skin temperature %@" } }, "es": { "stringUnit": { "state": "translated", "value": "Temperatura de la piel %@" } }, "fr": { "stringUnit": { "state": "translated", "value": "Température cutanée %@" } }, "it": { "stringUnit": { "state": "translated", "value": "Temperatura cutanea %@" } }, "pl": { "stringUnit": { "state": "translated", "value": "Temperatura skóry %@" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Temperatura da pele %@" } }, "ru": { "stringUnit": { "state": "translated", "value": "Температура кожи %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "皮肤温度 %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "皮膚溫度 %@" } } } }, "Respiration up": { "localizations": { "de": { "stringUnit": { "state": "translated", "value": "Atemfrequenz erhöht" } }, "en": { "stringUnit": { "state": "translated", "value": "Respiration up" } }, "es": { "stringUnit": { "state": "translated", "value": "Respiración elevada" } }, "fr": { "stringUnit": { "state": "translated", "value": "Respiration élevée" } }, "it": { "stringUnit": { "state": "translated", "value": "Respirazione elevata" } }, "pl": { "stringUnit": { "state": "translated", "value": "Oddech podwyższony" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Respiração elevada" } }, "ru": { "stringUnit": { "state": "translated", "value": "Частота дыхания повышена" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "呼吸频率升高" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "呼吸頻率升高" } } @@ -54,6 +120,9 @@ "zh-Hans": { "stringUnit": { "state": "translated", "value": "部分信号升高,但你记录了%@。这更可能是原因。设备端估算,不是诊断。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "部分訊號升高,但你記錄了%@。這更可能是原因。裝置端估算,不是診斷。" } } } }, + "The line breaks where the estimation method changed or was not recorded.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Die Linie bricht dort ab, wo sich die Schätzmethode geändert hat oder nicht erfasst wurde." } }, "en": { "stringUnit": { "state": "translated", "value": "The line breaks where the estimation method changed or was not recorded." } }, "es": { "stringUnit": { "state": "translated", "value": "La línea se interrumpe donde el método de estimación cambió o no se registró." } }, "fr": { "stringUnit": { "state": "translated", "value": "La courbe est interrompue là où la méthode d’estimation a changé ou n’a pas été enregistrée." } }, "it": { "stringUnit": { "state": "translated", "value": "La linea si interrompe dove il metodo di stima è cambiato o non è stato registrato." } }, "pl": { "stringUnit": { "state": "translated", "value": "Linia jest przerwana tam, gdzie metoda szacowania zmieniła się lub nie została zapisana." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A linha é interrompida onde o método de estimativa mudou ou não foi registado." } }, "ru": { "stringUnit": { "state": "translated", "value": "Линия прерывается там, где метод оценки изменился или не был записан." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "线条在估算方法发生变化或未记录之处中断。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "線條在估算方法變更或未記錄之處中斷。" } } + } }, "A few signals are mildly up: %@. Nothing alarming, but a calmer day may help. On-device estimate, not a diagnosis.": { "localizations": { "de": { "stringUnit": { "state": "translated", "value": "Einige Signale sind leicht erhöht: %@. Kein Grund zur Sorge, aber ein ruhigerer Tag könnte helfen. Schätzung auf dem Gerät, keine Diagnose." } }, "en": { "stringUnit": { "state": "translated", "value": "A few signals are mildly up: %@. Nothing alarming, but a calmer day may help. On-device estimate, not a diagnosis." } }, "es": { "stringUnit": { "state": "translated", "value": "Algunas señales están ligeramente elevadas: %@. No es alarmante, pero un día más tranquilo podría ayudar. Estimación en el dispositivo, no es un diagnóstico." } }, "fr": { "stringUnit": { "state": "translated", "value": "Quelques signaux sont légèrement élevés : %@. Rien d’alarmant, mais une journée plus calme peut aider. Estimation sur l’appareil, pas un diagnostic." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Alguns sinais estão ligeiramente elevados: %@. Não é alarmante, mas um dia mais calmo pode ajudar. Estimativa no dispositivo, não é um diagnóstico." } }, "it": { "stringUnit": { "state": "translated", "value": "Alcuni segnali sono leggermente elevati: %@. Nulla di allarmante, ma una giornata più tranquilla può aiutare. Stima sul dispositivo, non è una diagnosi." } }, @@ -676,6 +745,12 @@ "state": "translated", "value": "%@ · typowo %@ do %@, %lld." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": " %@ · обычно от %@ до %@, %lld." + } } } }, @@ -1006,6 +1081,12 @@ "state": "translated", "value": "%.2f V" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%.2f В" + } } } }, @@ -7377,6 +7458,12 @@ "state": "translated", "value": "%@: %@. Za mało historii dla typowego asortymentu." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@: %@. Пока недостаточно истории для типичного диапазона." + } } } }, @@ -8559,6 +8646,12 @@ "state": "translated", "value": "%lld kilka dni temu" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%lld дней назад" + } } } }, @@ -13630,6 +13723,12 @@ "state": "translated", "value": "%lld-%lld%@ · krok %lld" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%lld–%lld%@ · шаг %lld" + } } } }, @@ -14262,6 +14361,12 @@ "state": "translated", "value": "%lld–%lld bpm" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%lld–%lld уд/мин" + } } } }, @@ -14836,6 +14941,12 @@ "state": "translated", "value": "Skala 0-%lld" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Шкала 0–%lld" + } } } }, @@ -15798,6 +15909,12 @@ "state": "translated", "value": "1 minuta" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "1 мин" + } } } }, @@ -16946,6 +17063,12 @@ "state": "translated", "value": "1 tydzień" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "1 неделя" + } } } }, @@ -16980,6 +17103,12 @@ "state": "translated", "value": "1 trening" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "1 тренировка" + } } } }, @@ -17594,6 +17723,12 @@ "state": "translated", "value": "2 dni" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "2 дня" + } } } }, @@ -17628,6 +17763,12 @@ "state": "translated", "value": "2 minuty" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "2 мин" + } } } }, @@ -17662,6 +17803,12 @@ "state": "translated", "value": "2 tygodnie" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "2 недели" + } } } }, @@ -18282,6 +18429,12 @@ "state": "translated", "value": "5 min" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "5 мин" + } } } }, @@ -18664,6 +18817,12 @@ "state": "translated", "value": "Wartość bazowa 7 nocy %lld ms, normalna %lld do %lld ms%@" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Норма за 7 ночей %lld мс, обычный диапазон %lld–%lld мс%@" + } } } }, @@ -19302,6 +19461,12 @@ "state": "translated", "value": "Skrót chce dodać dni %lld i treningi %lld do źródła importu Apple Health." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Команда хочет добавить %lld дней и %lld тренировок в источник импорта Apple Health." + } } } }, @@ -19336,6 +19501,12 @@ "state": "translated", "value": "Skrót chce dodać dane do źródła importu Apple Health." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Команда хочет добавить данные в источник импорта Apple Health." + } } } }, @@ -21504,6 +21675,12 @@ "state": "translated", "value": "Szturchnięcie raz dziennie, gdy Twój Wysiłek osiągnie dolną granicę dzisiejszego optymalnego zakresu obciążenia, obliczonego na podstawie Twojego powrotu do zdrowia." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Одно напоминание в день, когда ваше Усилие достигает нижней границы сегодняшнего оптимального диапазона нагрузки, рассчитанного по вашему восстановлению." + } } } }, @@ -21815,7 +21992,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ein Read-Only Plews / Altini am kleinsten Wert während der Änderung Ihrer nächtlichen HRV: Es zeigt an, ob Ihre 7-Nächte-HRV-Basislinie über, innerhalb oder unter Ihrem persönlichen normalen Band liegt. Es ändert nichts anderes - der Energiering ist identisch, ob dieser ein- oder ausgeschaltet ist. Dies ist eine grobe / frühe Prüfung, die noch nicht gegen unterschiedliche reale Daten (n = 1) validiert wurde." + "value": "Ein Read-Only Plews / Altini am kleinsten Wert während der Änderung deiner nächtlichen HRV: Es zeigt an, ob deine 7-Nächte-HRV-Basislinie über, innerhalb oder unter deinem persönlichen normalen Band liegt. Es ändert nichts anderes - der Energiering ist identisch, ob dieser ein- oder ausgeschaltet ist. Dies ist eine grobe / frühe Prüfung, die noch nicht gegen unterschiedliche reale Daten (n = 1) validiert wurde." } }, "es": { @@ -21841,6 +22018,12 @@ "state": "translated", "value": "Odczyt tylko do odczytu Plews/Altini dotyczący najmniejszej i wartej uwagi zmiany twojego nocnego HRV: pokazuje, czy twoja 7-dniowa linia bazowa HRV znajduje się powyżej, wewnątrz czy poniżej twojego osobistego normalnego pasma. Nie zmienia to niczego innego - pierścień Energia jest identyczny niezależnie od tego, czy jest on włączony, czy wyłączony. Są to przybliżone/wczesne testy, które nie zostały jeszcze zweryfikowane w oparciu o różne rzeczywiste dane (n=1)." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показатель ночной ВСР по методу наименьшего значимого изменения Plews/Altini, только для чтения: показывает, находится ли ваша 7-ночная норма ВСР выше, внутри или ниже вашего личного нормального диапазона. Больше он ничего не меняет — кольцо Заряда одинаково при включённой и выключенной настройке. Это черновое, раннее тестирование, ещё не проверенное на разнообразных реальных данных (n=1)." + } } } }, @@ -22552,7 +22735,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ein transparentes kardiorespiratorisches Rezept, das sich tief und REM besser erholt als das ältere V1-Staging und jetzt standardmäßig ist. Es ändert sich nur, wie bereits erkannte Nächte in Etappen unterteilt sind (Erkennung und Ergebnisse sind unverändert); Schalten Sie es aus, um auf V1 zurückzugreifen. Tritt in den nächsten Nächten inszeniert." + "value": "Ein transparentes kardiorespiratorisches Rezept, das sich tief und REM besser erholt als das ältere V1-Staging und jetzt standardmäßig ist. Es ändert sich nur, wie bereits erkannte Nächte in Etappen unterteilt sind (Erkennung und Ergebnisse sind unverändert); Schalte es aus, um auf V1 zurückzugreifen. Tritt in den nächsten Nächten inszeniert." } }, "es": { @@ -22578,6 +22761,12 @@ "state": "translated", "value": "Przejrzysta receptura krążeniowo-oddechowa, która regeneruje głębiej i w fazie REM lepiej niż starsza wersja V1 i jest teraz domyślna. Zmienia się jedynie sposób podziału już wykrytych nocy na etapy (wykrywanie i wyniki pozostają niezmienione); wyłącz go, aby powrócić do wersji V1. Obowiązuje od kolejnych nocy scenicznych." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Прозрачная кардиореспираторная методика, которая восстанавливает глубокий сон и REM лучше прежней разметки V1 и теперь используется по умолчанию. Она меняет только то, как уже определённые ночи делятся на стадии (определение и оценки не меняются); выключите, чтобы вернуться к V1. Вступает в силу для следующих размеченных ночей." + } } } }, @@ -25894,7 +26083,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Fügen Sie Ihre Oura-App-Anmeldeinformationen zu OuraSecrets.xcconfig hinzu, um dies zu ermöglichen." + "value": "Füge deine Oura-App-Anmeldeinformationen zu OuraSecrets.xcconfig hinzu, um dies zu ermöglichen." } }, "es": { @@ -25920,6 +26109,12 @@ "state": "translated", "value": "Dodaj poświadczenia aplikacji Oura do pliku OuraSecrets.xcconfig, aby to włączyć." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавьте учётные данные приложения Oura в OuraSecrets.xcconfig, чтобы включить это." + } } } }, @@ -26650,6 +26845,12 @@ "state": "translated", "value": "Po wysiłku o dużej intensywności" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "После высокоинтенсивной нагрузки" + } } } }, @@ -27288,6 +27489,12 @@ "state": "translated", "value": "Wszystkie działania" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Все активности" + } } } }, @@ -29704,7 +29911,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Bewaffnet am Riemen selbst mit dem experimentellen Befehl 5/MG. Ein gurtgetriebener Wake ist immer noch unbestätigt auf 5/MG auf unserer Seite (bestätigt nur auf WHOOP 4.0), also halten Sie einen Backup-Alarm für alles, was Sie wirklich nicht verpassen können." + "value": "Bewaffnet am Riemen selbst mit dem experimentellen Befehl 5/MG. Ein gurtgetriebener Wake ist immer noch unbestätigt auf 5/MG auf unserer Seite (bestätigt nur auf WHOOP 4.0), also halte einen Backup-Alarm für alles, was du wirklich nicht verpassen kannst." } }, "es": { @@ -29730,6 +29937,12 @@ "state": "translated", "value": "Uzbrojony w sam pasek za pomocą eksperymentalnego polecenia 5/MG. Budzenie napędzane paskiem jest nadal niepotwierdzone na 5/MG po naszej stronie (potwierdzone tylko na WHOOP 4.0), więc miej alarm zapasowy na wypadek wszystkiego, czego naprawdę nie możesz przegapić." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Устанавливается на самом браслете экспериментальной командой 5/MG. Пробуждение с браслета на 5/MG у нас пока не подтверждено (подтверждено только на WHOOP 4.0), поэтому оставьте резервный будильник для всего, что действительно нельзя пропустить." + } } } }, @@ -29738,7 +29951,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Bewaffnet auf dem gurt selbst, so dass es zu ihrer wachzeit summen kann, auch wenn ihr telefon schläft oder xnoopx geschlossen ist. Sendet den genauen Alarmbefehl, den die offizielle App sendet, bestätigtes Summen auf einem echten WHOOP 4.0 (Community-Wire-Capture + On-Device-Test, #535). Halten Sie einen Backup-Alarm für alles, was Sie wirklich nicht verpassen können." + "value": "Bewaffnet auf dem gurt selbst, so dass es zu ihrer wachzeit summen kann, auch wenn ihr telefon schläft oder xnoopx geschlossen ist. Sendet den genauen Alarmbefehl, den die offizielle App sendet, bestätigtes Summen auf einem echten WHOOP 4.0 (Community-Wire-Capture + On-Device-Test, #535). Halte einen Backup-Alarm für alles, was du wirklich nicht verpassen kannst." } }, "es": { @@ -29764,6 +29977,12 @@ "state": "translated", "value": "Uzbrojony w sam pasek, dzięki czemu może wibrować, gdy się obudzisz, nawet jeśli Twój telefon jest uśpiony lub NOOP jest zamknięty. Wysyła dokładną komendę alarmową wysyłaną przez oficjalną aplikację, potwierdzone brzęczenie na prawdziwym WHOOP 4.0 (przechwytywanie połączeń społecznościowych + test na urządzeniu, #535). Zachowaj alarm zapasowy na wypadek wszystkiego, czego naprawdę nie możesz przegapić." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Устанавливается на самом браслете, поэтому он может завибрировать в час пробуждения, даже если телефон спит или NOOP закрыт. Отправляет ровно ту команду будильника, что и официальное приложение; вибрация подтверждена на настоящем WHOOP 4.0 (перехват трафика сообществом + проверка на устройстве, #535). Оставьте резервный будильник для всего, что действительно нельзя пропустить." + } } } }, @@ -29830,7 +30049,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Armiert den Riemen, um zu Ihrer Weckzeit zu summen, auch wenn NOOP geschlossen ist. Sendet den genauen Alarmbefehl, den die offizielle App sendet, bestätigtes Summen auf einem echten WHOOP 4.0 (Community-Wire-Capture + On-Device-Test, #535). Halten Sie einen Backup-Alarm für alles, was Sie wirklich nicht verpassen können." + "value": "Armiert den Riemen, um zu deiner Weckzeit zu summen, auch wenn NOOP geschlossen ist. Sendet den genauen Alarmbefehl, den die offizielle App sendet, bestätigtes Summen auf einem echten WHOOP 4.0 (Community-Wire-Capture + On-Device-Test, #535). Halte einen Backup-Alarm für alles, was du wirklich nicht verpassen kannst." } }, "es": { @@ -29856,6 +30075,12 @@ "state": "translated", "value": "Uzbraja pasek, aby brzęczał w momencie przebudzenia, nawet jeśli NOOP jest zamknięty. Wysyła dokładną komendę alarmową wysyłaną przez oficjalną aplikację, potwierdzone brzęczenie na prawdziwym WHOOP 4.0 (przechwytywanie połączeń społecznościowych + test na urządzeniu, #535). Zachowaj alarm zapasowy na wypadek wszystkiego, czego naprawdę nie możesz przegapić." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Настраивает браслет на вибрацию в час пробуждения, даже если NOOP закрыт. Отправляет ровно ту команду будильника, что и официальное приложение; вибрация подтверждена на настоящем WHOOP 4.0 (перехват трафика сообществом + проверка на устройстве, #535). Оставьте резервный будильник для всего, что действительно нельзя пропустить." + } } } }, @@ -29937,6 +30162,12 @@ "value": "Organiser Aujourd'hui" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Настроить «Сегодня»" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -34524,6 +34755,12 @@ "state": "translated", "value": "Napięcie akumulatora %.2f w woltach" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Напряжение батареи %.2f вольт" + } } } }, @@ -34558,6 +34795,12 @@ "state": "translated", "value": "Sonda informacji o akumulatorze (#592 RE)" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проба сведений о батарее (#592 RE)" + } } } }, @@ -34592,6 +34835,12 @@ "state": "translated", "value": "Sonda informacji o akumulatorze (#592 RE)…" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проба сведений о батарее (#592 RE)…" + } } } }, @@ -34626,6 +34875,12 @@ "state": "translated", "value": "Wynik sondy informacji o akumulatorze (#592)" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Результат пробы батареи (#592)" + } } } }, @@ -36552,6 +36807,12 @@ "state": "translated", "value": "Sonda do lokalizacji ciała (#690 RE)" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проба положения на теле (#690 RE)" + } } } }, @@ -36586,6 +36847,12 @@ "state": "translated", "value": "Sonda do lokalizacji ciała (#690 RE)…" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проба положения на теле (#690 RE)…" + } } } }, @@ -36620,6 +36887,12 @@ "state": "translated", "value": "Wynik sondy lokalizacji ciała (#690)" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Результат пробы положения на теле (#690)" + } } } }, @@ -37806,7 +38079,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Geben Sie einen Marker CSV (Datum, Marker, Wert, Einheit). Namen, die mit der Katalogfaltung auf Ihre vorhandenen Markierungen übereinstimmen; alles andere kommt als benutzerdefinierte Markierung vor. Zeilen, die nicht gelesen werden können, werden übersprungen und gezählt, nie erraten. Alles, was Sie importieren, bleibt auf %1$@." + "value": "Gib einen Marker CSV (Datum, Marker, Wert, Einheit). Namen, die mit der Katalogfaltung auf deine vorhandenen Markierungen übereinstimmen; alles andere kommt als benutzerdefinierte Markierung vor. Zeilen, die nicht gelesen werden können, werden übersprungen und gezählt, nie erraten. Alles, was du importierst, bleibt auf %1$@." } }, "es": { @@ -37832,6 +38105,12 @@ "state": "translated", "value": "Wprowadź znaczniki CSV (data, znacznik, wartość, jednostka). Nazwy pasujące do katalogu należy złożyć na istniejące znaczniki; wszystko inne pojawia się jako znacznik niestandardowy. Wiersze, których nie można odczytać, są pomijane i liczone, nigdy nie zgadywane. Wszystko, co importujesz, pozostaje w %@." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Загрузите CSV с маркерами (дата, маркер, значение, единица). Названия, совпадающие с каталогом, объединяются с вашими существующими маркерами; всё остальное добавляется как свой маркер. Строки, которые не удалось прочитать, пропускаются и подсчитываются — никогда не угадываются. Всё импортированное остаётся на %@." + } } } }, @@ -37913,6 +38192,12 @@ "value": "Diffusion des ressources humaines de ce téléphone" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Транслировать пульс с этого телефона" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -38185,6 +38470,12 @@ "value": "Sangle de diffusion HR (Garmin/ANT)" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Транслировать пульс браслета (Garmin/ANT)" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -41332,6 +41623,12 @@ "state": "translated", "value": "Kalibracja (%lld/%lld w nocy)" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Калибровка (%lld из %lld ночей)" + } } } }, @@ -42526,6 +42823,9 @@ } } }, + "Carried · %@": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Übernommen · %@" } }, "es": { "stringUnit": { "state": "translated", "value": "Heredado · %@" } }, "fr": { "stringUnit": { "state": "translated", "value": "Reporté · %@" } }, "it": { "stringUnit": { "state": "translated", "value": "Riportato · %@" } }, "pl": { "stringUnit": { "state": "translated", "value": "Przeniesione · %@" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Herdado · %@" } }, "ru": { "stringUnit": { "state": "translated", "value": "Перенесено · %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "携带 · %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "攜带 · %@" } } + } }, "Carbs": { "localizations": { "de": { @@ -43091,6 +43391,12 @@ "state": "translated", "value": "Zmień na numer" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сменить на число" + } } } }, @@ -43125,6 +43431,12 @@ "state": "translated", "value": "Zmień na Tak/Nie" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сменить на «Да/Нет»" + } } } }, @@ -44305,7 +44617,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Energie gewichtet Ihre HRV gegenüber Ihrer persönlichen Basislinie (~55%%), Ruheherzfrequenz (~20%%), Erholungsqualität (~15%%), Atmung (~5%%) und Hauttemperaturabweichung (~5%%). Belastung ist eine kardiovaskuläre Belastung von 0–%lld aus der Zeit in Herzfrequenzzonen. Erholung wird aus Bewegung und Herzfrequenz in Schlafphasen eingeteilt. Alles wird hier aus den Rohdaten des Straps berechnet." + "value": "Energie gewichtet deine HRV gegenüber deiner persönlichen Basislinie (~55%%), Ruheherzfrequenz (~20%%), Erholungsqualität (~15%%), Atmung (~5%%) und Hauttemperaturabweichung (~5%%). Belastung ist eine kardiovaskuläre Belastung von 0–%lld aus der Zeit in Herzfrequenzzonen. Erholung wird aus Bewegung und Herzfrequenz in Schlafphasen eingeteilt. Alles wird hier aus den Rohdaten des Straps berechnet." } }, "es": { @@ -44331,6 +44643,12 @@ "state": "translated", "value": "Energia porównuje Twój HRV z Twoim osobistym poziomem bazowym (~55%%), tętnem spoczynkowym (~20%%), jakością odpoczynku (~15%%), oddychaniem (~5%%) i odchyleniem temperatury skóry (~5%%). Wysiłek to obciążenie układu krążenia 0-%lld od czasu w strefach tętna. Odpoczynek jest inscenizowany na podstawie ruchu i tętna. Wszystko jest tutaj obliczane na podstawie surowych danych paska. Działa każdego dnia NOOP zbiera surowe strumienie." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Заряд сопоставляет вашу ВСР с личной нормой (~55%%), пульс в покое (~20%%), качество отдыха (~15%%), дыхание (~5%%) и отклонение температуры кожи (~5%%). Усилие — это сердечно-сосудистая нагрузка 0–%lld по времени в зонах пульса. Отдых размечается по движению и пульсу. Всё вычисляется здесь по сырым данным браслета. Работает для любого дня, за который NOOP собрал сырые потоки." + } } } }, @@ -50640,6 +50958,390 @@ } } }, + "Live heart rate is streaming, but your strap is not fully paired. Buzz, alarms and history sync need the encrypted pairing.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Live-Herzfrequenz wird übertragen, aber dein Strap ist nicht vollständig gekoppelt. Vibration, Wecker und Verlaufssynchronisierung benötigen die verschlüsselte Kopplung." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Live heart rate is streaming, but your strap is not fully paired. Buzz, alarms and history sync need the encrypted pairing." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La frecuencia cardíaca en tiempo real se está transmitiendo, pero tu pulsera no está completamente emparejada. La vibración, las alarmas y la sincronización del historial necesitan el emparejamiento cifrado." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La fréquence cardiaque en direct est diffusée, mais votre bracelet n'est pas entièrement associé. Les vibrations, les alarmes et la synchronisation de l'historique nécessitent l'association chiffrée." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La frequenza cardiaca in tempo reale è in streaming, ma la tua fascia non è completamente associata. Vibrazione, sveglie e sincronizzazione della cronologia richiedono l'associazione cifrata." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Tętno na żywo jest przesyłane, ale twój pasek nie jest w pełni sparowany. Wibracje, alarmy i synchronizacja historii wymagają szyfrowanego parowania." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "A frequência cardíaca em tempo real está a ser transmitida, mas a tua bracelete não está totalmente emparelhada. A vibração, os alarmes e a sincronização do histórico necessitam do emparelhamento encriptado." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пульс в реальном времени передаётся, но ваш браслет сопряжён не полностью. Вибрация, будильники и синхронизация истории требуют зашифрованного сопряжения." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "实时心率正在传输,但你的手环尚未完全配对。震动、闹钟和历史同步需要加密配对。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "即時心率正在傳輸,但你的手環尚未完全配對。震動、鬧鐘和歷史同步需要加密配對。" + } + } + } + }, + "Check automatically": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatisch prüfen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Check automatically" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Comprobar automáticamente" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérifier automatiquement" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Controlla automaticamente" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Sprawdzaj automatycznie" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Verificar automaticamente" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверять автоматически" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动检查" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動檢查" + } + } + } + }, + "Once a day, NOOP asks GitHub for the latest version number and puts a note in Updates if there's a newer one. Nothing about you is sent, and it never installs anything.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Einmal täglich fragt NOOP bei GitHub die neueste Versionsnummer ab und legt einen Hinweis in Updates ab, wenn es eine neuere gibt. Es werden keine Daten über dich gesendet, und es wird nie etwas installiert." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Once a day, NOOP asks GitHub for the latest version number and puts a note in Updates if there's a newer one. Nothing about you is sent, and it never installs anything." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Una vez al día, NOOP consulta a GitHub el número de la última versión y deja un aviso en Novedades si hay una más reciente. No se envía nada sobre ti y nunca instala nada." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Une fois par jour, NOOP demande à GitHub le numéro de la dernière version et dépose une note dans Mises à jour s'il en existe une plus récente. Aucune donnée vous concernant n'est envoyée, et rien n'est jamais installé." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Una volta al giorno, NOOP chiede a GitHub il numero dell'ultima versione e lascia una nota in Aggiornamenti se ne esiste una più recente. Non viene inviato nulla su di te e non installa mai nulla." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Raz dziennie NOOP pyta GitHub o numer najnowszej wersji i dodaje notatkę w Aktualizacjach, jeśli jest nowsza. Nic o tobie nie jest wysyłane i nic nigdy nie jest instalowane." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Uma vez por dia, o NOOP pergunta ao GitHub o número da versão mais recente e deixa uma nota em Atualizações se existir uma mais recente. Não é enviado nada sobre si e nunca instala nada." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Раз в день NOOP запрашивает у GitHub номер последней версии и оставляет заметку в «Обновлениях», если появилась более новая. Ничего о вас не отправляется, и ничего никогда не устанавливается." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "NOOP 每天向 GitHub 查询一次最新版本号,如果有更新版本,就在「更新」中留下提示。不会发送任何与你有关的信息,也绝不会安装任何东西。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "NOOP 每天向 GitHub 查詢一次最新版本號,如果有更新版本,就在「更新」中留下提示。不會傳送任何與你有關的資訊,也絕不會安裝任何東西。" + } + } + } + }, + "NOOP %@ is available": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "NOOP %@ ist verfügbar" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "NOOP %@ is available" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "NOOP %@ ya está disponible" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "NOOP %@ est disponible" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "NOOP %@ è disponibile" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "NOOP %@ jest dostępny" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "O NOOP %@ está disponível" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Доступна версия NOOP %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "NOOP %@ 可用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "NOOP %@ 可用" + } + } + } + }, + "You're on %@. Open Settings and use Check for updates to see what's new and download %@.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Du nutzt %@. Öffne die Einstellungen und tippe auf „Nach Updates suchen“, um die Neuerungen zu sehen und %@ herunterzuladen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "You're on %@. Open Settings and use Check for updates to see what's new and download %@." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tienes la %@. Abre Ajustes y usa Buscar actualizaciones para ver las novedades y descargar la %@." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vous utilisez la version %@. Ouvrez les Réglages et utilisez Rechercher des mises à jour pour voir les nouveautés et télécharger la %@." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Stai usando la %@. Apri Impostazioni e usa Cerca aggiornamenti per vedere le novità e scaricare la %@." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Masz wersję %@. Otwórz Ustawienia i użyj opcji Sprawdź aktualizacje, aby zobaczyć nowości i pobrać %@." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Tem a versão %@. Abra as Definições e use Procurar atualizações para ver as novidades e transferir a %@." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "У вас версия %@. Откройте «Настройки» и нажмите «Проверить обновления», чтобы посмотреть, что нового, и загрузить %@." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你当前使用 %@。打开「设置」并使用「检查更新」查看新增内容并下载 %@。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你目前使用 %@。開啟「設定」並使用「檢查更新」查看新增內容並下載 %@。" + } + } + } + }, + "AltStore or SideStore can install it for you automatically if you added NOOP's source; a direct .ipa still has to be signed on your device.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "AltStore oder SideStore installieren es automatisch für dich, wenn du NOOPs Quelle hinzugefügt hast; eine direkte .ipa muss weiterhin auf deinem Gerät signiert werden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "AltStore or SideStore can install it for you automatically if you added NOOP's source; a direct .ipa still has to be signed on your device." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "AltStore o SideStore pueden instalarla automáticamente si añadiste la fuente de NOOP; un .ipa directo aún debe firmarse en tu dispositivo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "AltStore ou SideStore peuvent l'installer automatiquement si vous avez ajouté la source de NOOP ; un .ipa direct doit toujours être signé sur votre appareil." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "AltStore o SideStore possono installarla automaticamente se hai aggiunto la sorgente di NOOP; un .ipa diretto deve comunque essere firmato sul tuo dispositivo." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "AltStore lub SideStore zainstalują ją automatycznie, jeśli dodano źródło NOOP; bezpośredni plik .ipa nadal trzeba podpisać na urządzeniu." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "O AltStore ou o SideStore podem instalá-la automaticamente se adicionou a fonte do NOOP; um .ipa direto continua a ter de ser assinado no seu dispositivo." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "AltStore или SideStore установят её автоматически, если вы добавили источник NOOP; прямой .ipa всё равно нужно подписать на устройстве." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "如果你添加了 NOOP 的源,AltStore 或 SideStore 可以自动为你安装;直接下载的 .ipa 仍需在设备上签名。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "如果你加入了 NOOP 的來源,AltStore 或 SideStore 可以自動為你安裝;直接下載的 .ipa 仍需在裝置上簽名。" + } + } + } + }, "Connected. Finishing the secure pairing handshake…": { "localizations": { "de": { @@ -55679,6 +56381,12 @@ "state": "translated", "value": "Dzień 1: jasne światło %@-%@, wyłączenie świateł wokół %@." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "День 1: яркий свет %@–%@, отбой около %@." + } } } }, @@ -57428,6 +58136,12 @@ "value": "Carreaux détaillés" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подробные плитки" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -61111,6 +61825,12 @@ "state": "translated", "value": "ALGORYTMY EKSPERYMENTALNE" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Экспериментальные алгоритмы" + } } } }, @@ -61447,6 +62167,12 @@ "state": "translated", "value": "Każda linia pokazuje, o ile uderzeń na minutę zmieniło się tętno po wysiłku. Uwzględniane są tylko treningi o wysokiej intensywności z zarejestrowanym tętnem po treningu." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Каждая линия показывает, на сколько ударов в минуту изменился ваш пульс после нагрузки. Учитываются только высокоинтенсивные тренировки с записанным пульсом после тренировки." + } } } }, @@ -70222,6 +70948,12 @@ "state": "translated", "value": "Zapomnij o dostępie Oura" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Забыть доступ к Oura" + } } } }, @@ -72730,6 +73462,12 @@ "state": "translated", "value": "Grupa" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Группа" + } } } }, @@ -73118,6 +73856,12 @@ "state": "translated", "value": "GODZINY SNU" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ЧАСОВ СНА" + } } } }, @@ -73861,6 +74605,12 @@ "value": "Interpolation du sous-logement HR-from-PPG (v26)" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пульс из PPG: интерполяция внутри задержки (заполнение пропусков v26)" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -74487,6 +75237,12 @@ "value": "Préparation à la VFC (Plews/Altini)" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Готовность по ВСР (Plews/Altini)" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -74527,6 +75283,12 @@ "value": "Préparation à la VFC (expérimental)" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Готовность по ВСР (эксперимент)" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -74578,6 +75340,12 @@ "state": "translated", "value": "Gotowość HRV (eksperymentalna): %@" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Готовность по ВСР (эксперимент): %@" + } } } }, @@ -74717,6 +75485,12 @@ "value": "Fenêtre VFC" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Окно ВСР" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -75161,7 +75935,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Heads up: Dieser Testmodus ist ausgeschaltet, so dass der Bericht keine Erfassung dafür hat. Für einen nützlichen Bericht schalten Sie den Modus ein, reproduzieren das Problem beim Tragen des Riemens und melden Sie es erneut." + "value": "Heads up: Dieser Testmodus ist ausgeschaltet, so dass der Bericht keine Erfassung dafür hat. Für einen nützlichen Bericht schalte den Modus ein, reproduzieren das Problem beim Tragen des Riemens und melde es erneut." } }, "es": { @@ -75187,6 +75961,12 @@ "state": "translated", "value": "Uwaga: ten tryb testowy jest wyłączony, więc raport nie zawiera jego przechwytywania. Aby otrzymać przydatny raport, włącz tryb, odtwórz problem podczas noszenia paska, a następnie zgłoś ponownie." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Внимание: этот тестовый режим выключен, поэтому в отчёте нет соответствующего захвата. Чтобы отчёт был полезен, включите режим, воспроизведите проблему в надетом браслете и отправьте отчёт заново." + } } } }, @@ -75709,6 +76489,12 @@ "state": "translated", "value": "Tętno Regeneracja" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Восстановление пульса" + } } } }, @@ -76155,6 +76941,12 @@ "state": "translated", "value": "Odzyskiwanie tętna przy %@, %lld uderzeń na minutę" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Восстановление пульса на %@, %lld ударов в минуту" + } } } }, @@ -76189,6 +76981,12 @@ "state": "translated", "value": "Odzyskiwanie tętna przy %@, niedostępne" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Восстановление пульса на %@ — нет данных" + } } } }, @@ -76635,6 +77433,12 @@ "state": "translated", "value": "Trend odzyskiwania tętna w uderzeniach na minutę" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Тренд восстановления пульса в ударах в минуту" + } } } }, @@ -76669,6 +77473,12 @@ "state": "translated", "value": "Odzyskiwanie tętna · %@" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Восстановление пульса · %@" + } } } }, @@ -77760,6 +78570,12 @@ "value": "L'histoire reste sous la question originale donc WHOOP importations toujours en ligne." } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "История остаётся под исходным вопросом, чтобы импорт из WHOOP по-прежнему совпадал." + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -79876,7 +80692,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Wie man das liest: HRV, Ruhe HR, Schlafdauer, Atemfrequenz und Hauttemperatur werden vom Band gemessen (Hauttemperatur wird als Abweichung von der eigenen Baseline angezeigt). Workouts ist die Anzahl der Aktivitäten, die Sie protokolliert haben oder die erkannt wurden. Recovery, Strain und Stress sind die eigenen On-Device-Scores von NOOP, keine klinischen Maßnahmen: Erholung ist eine tägliche Bereitschaft Komposit (HRV, Ruhe HR, Schlaf und Haut-Temper-Trend), Strain ist Herz-Kreislauf-Last von der Herzfrequenz abgeleitet, und Stress ist ein 0-3 autonome Last-Index von Ruhe HR und HRV." + "value": "Wie man das liest: HRV, Ruhe HR, Schlafdauer, Atemfrequenz und Hauttemperatur werden vom Band gemessen (Hauttemperatur wird als Abweichung von der eigenen Baseline angezeigt). Workouts ist die Anzahl der Aktivitäten, die du protokolliert hast oder die erkannt wurden. Recovery, Strain und Stress sind die eigenen On-Device-Scores von NOOP, keine klinischen Maßnahmen: Erholung ist eine tägliche Bereitschaft Komposit (HRV, Ruhe HR, Schlaf und Haut-Temper-Trend), Strain ist Herz-Kreislauf-Last von der Herzfrequenz abgeleitet, und Stress ist ein 0-3 autonome Last-Index von Ruhe HR und HRV." } }, "es": { @@ -79902,6 +80718,12 @@ "state": "translated", "value": "Jak to odczytać: HRV, tętno spoczynkowe, czas snu, częstość oddechów i temperatura skóry są mierzone za pomocą paska (temperatura skóry jest pokazana jako odchylenie od własnej wartości bazowej). Treningi to liczba zarejestrowanych lub wykrytych aktywności. Regeneracja, Obciążenie i Stres to własne wyniki NOOP na urządzeniu, a nie pomiary kliniczne: Regeneracja to połączenie codziennej gotowości (HRV, tętno spoczynkowe, trend snu i temperatury skóry), Obciążenie to obciążenie układu sercowo-naczyniowego obliczone na podstawie tętna, a Stres to wskaźnik obciążenia autonomicznego wynoszący 0-3 na podstawie tętna spoczynkowego i tętna HRV." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Как это читать: ВСР, пульс в покое, длительность сна, частота дыхания и температура кожи измеряются браслетом (температура кожи показана как отклонение от вашей собственной нормы). «Тренировки» — количество активностей, которые вы записали или которые были определены автоматически. Восстановление, Нагрузка и Стресс — собственные оценки NOOP на устройстве, а не клинические показатели: Восстановление — это суточный сводный показатель готовности (ВСР, пульс в покое, сон и тренд температуры кожи), Нагрузка — сердечно-сосудистая нагрузка, выведенная из пульса, а Стресс — индекс вегетативной нагрузки 0–3 по пульсу в покое и ВСР." + } } } }, @@ -81863,6 +82685,12 @@ "state": "translated", "value": "Zaimportować dane Apple Health?" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Импортировать данные Apple Health?" + } } } }, @@ -82187,6 +83015,12 @@ "state": "translated", "value": "Zaimportuj ponownie" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Импортировать снова" + } } } }, @@ -85283,6 +86117,12 @@ "value": "Rappel du journal" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Напоминание о журнале" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -85671,6 +86511,12 @@ "value": "Garder les derniers instantanés" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Хранить последние снимки" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -86181,6 +87027,12 @@ "value": "Démarrage à (batterie)" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включать при (заряде браслета)" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -90276,6 +91128,12 @@ "state": "translated", "value": "Liquid Today (prototyp)" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Liquid Today (прототип)" + } } } }, @@ -90769,6 +91627,12 @@ "value": "Sessions en direct (bêta)" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сессии в реальном времени (бета)" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -92998,6 +93862,12 @@ "value": "Remplir le journal du jour" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Заполнить журнал за сегодня" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -93688,6 +94558,12 @@ "value": "Des plages plus longues déverrouillent comme plus d'histoire construit." } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Более длинные диапазоны открываются по мере накопления истории." + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -93745,6 +94621,12 @@ "state": "translated", "value": "Najdłuższy: %lld dni" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Дольше всего: %lld дней" + } } } }, @@ -93785,6 +94667,12 @@ "state": "translated", "value": "Najdłuższy: 1 dzień" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Дольше всего: 1 день" + } } } }, @@ -94508,6 +95396,39 @@ } } }, + "Day starts": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Tagesbeginn" } }, + "es": { "stringUnit": { "state": "translated", "value": "El día empieza" } }, + "fr": { "stringUnit": { "state": "translated", "value": "Début de la journée" } }, + "it": { "stringUnit": { "state": "translated", "value": "Inizio giornata" } }, + "pl": { "stringUnit": { "state": "translated", "value": "Początek dnia" } }, + "pt-PT": { "stringUnit": { "state": "translated", "value": "O dia começa" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Начало дня" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "一天开始于" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "一天開始於" } } + } }, + "Default. Steps and in-progress Effort restart at the beginning of detected main sleep. Naps do not start a new day; missing sleep falls back to local midnight.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Standard. Schritte und die laufende Anstrengung beginnen mit dem erkannten Hauptschlaf neu. Nickerchen starten keinen neuen Tag; bei fehlendem Schlaf verwendet NOOP ersatzweise die lokale Mitternacht." } }, + "es": { "stringUnit": { "state": "translated", "value": "Predeterminado. Los pasos y el Esfuerzo en curso se reinician al comienzo del sueño principal detectado. Las siestas no empiezan un nuevo día; si faltan datos de sueño, se usa la medianoche local." } }, + "fr": { "stringUnit": { "state": "translated", "value": "Par défaut. Les pas et l’Effort en cours repartent de zéro au début du sommeil principal détecté. Les siestes ne commencent pas une nouvelle journée ; en l’absence de sommeil, l’heure locale de minuit est utilisée." } }, + "it": { "stringUnit": { "state": "translated", "value": "Predefinito. I passi e lo Sforzo in corso ripartono all’inizio del sonno principale rilevato. I sonnellini non iniziano un nuovo giorno; se mancano dati sul sonno, viene usata la mezzanotte locale." } }, + "pl": { "stringUnit": { "state": "translated", "value": "Domyślnie. Kroki i bieżący Wysiłek zaczynają się od nowa wraz z wykrytym głównym snem. Drzemki nie rozpoczynają nowego dnia; gdy brakuje danych o śnie, używana jest lokalna północ." } }, + "pt-PT": { "stringUnit": { "state": "translated", "value": "Predefinição. Os passos e o Esforço em curso recomeçam no início do sono principal detetado. As sestas não iniciam um novo dia; se faltarem dados de sono, é usada a meia-noite local." } }, + "ru": { "stringUnit": { "state": "translated", "value": "По умолчанию. Шаги и текущая Нагрузка обнуляются в начале распознанного основного сна. Дневной сон не начинает новый день; при отсутствии данных о сне используется местная полночь." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "默认。步数和进行中的体力消耗从检测到的主要睡眠开始时重新计算。小睡不会开始新的一天;缺少睡眠数据时,将使用当地午夜。" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "預設。步數和進行中的體力消耗從偵測到的主要睡眠開始時重新計算。小睡不會開始新的一天;缺少睡眠資料時,將使用當地午夜。" } } + } }, + "Uses a conventional local calendar day from 00:00 to 00:00.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Verwendet einen herkömmlichen lokalen Kalendertag von 00:00 bis 00:00 Uhr." } }, + "es": { "stringUnit": { "state": "translated", "value": "Usa un día natural local convencional de 00:00 a 00:00." } }, + "fr": { "stringUnit": { "state": "translated", "value": "Utilise une journée civile locale classique de 00:00 à 00:00." } }, + "it": { "stringUnit": { "state": "translated", "value": "Usa un giorno di calendario locale convenzionale dalle 00:00 alle 00:00." } }, + "pl": { "stringUnit": { "state": "translated", "value": "Używa zwykłego lokalnego dnia kalendarzowego od 00:00 do 00:00." } }, + "pt-PT": { "stringUnit": { "state": "translated", "value": "Usa um dia civil local convencional das 00:00 às 00:00." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Использует обычный местный календарный день с 00:00 до 00:00." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "使用从 00:00 到次日 00:00 的常规当地日历日。" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "使用從 00:00 到次日 00:00 的一般當地日曆日。" } } + } }, "Main sleep": { "localizations": { "de": { @@ -99064,6 +99985,12 @@ "value": "Raffinement de la sillage en mouvement" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Уточнение пробуждения по движению" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -99401,6 +100328,12 @@ "value": "Bouge de toute façon" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Всё равно перенести" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -99484,7 +100417,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Bewegen Sie diesen Schlaf?" + "value": "Bewege diesen Schlaf?" } }, "es": { @@ -99499,6 +100432,12 @@ "value": "Bouge ce sommeil ?" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перенести этот сон?" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -100534,7 +101473,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "NOOP bietet Ihnen drei tägliche Werte (Charge, Effort und Rest), jede auf einer 0-100-Skala. Sie werden aus den rohen Signalen Ihres Gurts mit veröffentlichten, von Experten begutachteten Sportwissenschaften aufgebaut und vollständig auf Ihrem Gerät berechnet. Sie sind NICHT die Ergebnisse von WHOOP: Wir haben keine privaten Algorithmen von WHOOP und tun nicht so, als ob. Sie zielen auf die gleichen drei Fragen mit Open Science, so dass sie normalerweise WHOOP in Richtung verfolgen, aber nicht mit Zahl für Zahl übereinstimmen. Und darum geht es." + "value": "NOOP bietet dir drei tägliche Werte (Charge, Effort und Rest), jede auf einer 0-100-Skala. Sie werden aus den rohen Signalen deines Gurts mit veröffentlichten, von Experten begutachteten Sportwissenschaften aufgebaut und vollständig auf deinem Gerät berechnet. Sie sind NICHT die Ergebnisse von WHOOP: Wir haben keine privaten Algorithmen von WHOOP und tun nicht so, als ob. Sie zielen auf die gleichen drei Fragen mit Open Science, so dass sie normalerweise WHOOP in Richtung verfolgen, aber nicht mit Zahl für Zahl übereinstimmen. Und darum geht es." } }, "es": { @@ -100560,6 +101499,12 @@ "state": "translated", "value": "NOOP daje trzy dzienne wyniki (Energia, Wysiłek i Odpoczynek), każdy w skali 0-100. Są one tworzone na podstawie surowych sygnałów Twojego paska na podstawie opublikowanych, recenzowanych wyników badań sportowych i obliczane w całości na Twoim urządzeniu. NIE są to wyniki WHOOP: nie mamy prywatnych algorytmów WHOOP i nie udajemy, że je mamy. Mają na celu te same trzy pytania, korzystając z otwartej nauki, więc zazwyczaj śledzą kierunek WHOOP, ale nie dopasowują liczby do liczby. I o to właśnie chodzi." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "NOOP даёт три ежедневные оценки (Заряд, Усилие и Отдых), каждая по шкале 0–100. Они строятся по сырым сигналам вашего браслета на основе опубликованной, прошедшей рецензирование спортивной науки и вычисляются целиком на вашем устройстве. Это НЕ оценки WHOOP: у нас нет закрытых алгоритмов WHOOP, и мы не притворяемся, что есть. Они отвечают на те же три вопроса средствами открытой науки, поэтому обычно совпадают с оценками WHOOP по направлению, но не сходятся число в число. В этом и смысл." + } } } }, @@ -106045,6 +106990,12 @@ "state": "translated", "value": "Brak szczegółów tętna tej nocy" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет данных о пульсе за эту ночь" + } } } }, @@ -106833,6 +107784,12 @@ "state": "translated", "value": "Żadnych nowych nocy z Twojego paska przez %lld dni. Sprawdź, czy jest podłączony i zapisuje dane." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет новых ночей с вашего браслета уже %lld дней. Проверьте, что он подключён и сохраняет данные." + } } } }, @@ -107221,6 +108178,12 @@ "state": "translated", "value": "Brak zdjęcia → rezerwowa osoba.przycięcie.koło" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет фото → запасной вариант person.crop.circle" + } } } }, @@ -111854,6 +112817,12 @@ "state": "translated", "value": "Powiadamiaj, kiedy zostanie osiągnięte optymalne napięcie" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Уведомлять о достижении оптимальной нагрузки" + } } } }, @@ -112907,7 +113876,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ältere Backups darüber hinaus werden viele beschnitten, älteste zuerst (≈ so viele Tage). Wenn Daten jemals korrumpiert werden, stellen Sie das Neueste wieder her." + "value": "Ältere Backups darüber hinaus werden viele beschnitten, älteste zuerst (≈ so viele Tage). Wenn Daten jemals korrumpiert werden, stelle das Neueste wieder her." } }, "es": { @@ -112933,6 +113902,12 @@ "state": "translated", "value": "Starsze kopie zapasowe przekraczające tę liczbę są usuwane w kolejności od najstarszej (≈ tyle dni). Jeśli dane kiedykolwiek ulegną uszkodzeniu, przywróć najnowszą wersję." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Более старые копии сверх этого числа удаляются, начиная с самых старых (≈ столько дней). Если данные когда-нибудь повредятся, восстановите самую свежую." + } } } }, @@ -114837,6 +115812,12 @@ "value": "Ouvrir le journal" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть журнал" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -115481,6 +116462,12 @@ "state": "translated", "value": "Osiągnięto optymalne napięcie" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Достигнута оптимальная нагрузка" + } } } }, @@ -118330,6 +119317,12 @@ "value": "Capture de la VFC en pause" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Приостановить сбор ВСР" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -118509,6 +119502,12 @@ "state": "translated", "value": "Szczyt %lld bpm" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пик %lld уд/мин" + } } } }, @@ -120229,7 +121228,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Bitte bestätigen Sie diese:" + "value": "Bitte bestätige diese:" } }, "es": { @@ -120244,6 +121243,12 @@ "value": "Veuillez confirmer chacun de ces éléments :" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пожалуйста, подтвердите каждый пункт:" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -120327,7 +121332,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Bitte lesen Sie die Punkte unten und bestätigen Sie dann jede Aussage." + "value": "Bitte lies die Punkte unten und bestätige dann jede Aussage." } }, "es": { @@ -120342,6 +121347,12 @@ "value": "Veuillez lire les points ci-dessous, puis confirmer chaque déclaration." } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пожалуйста, прочитайте пункты ниже, затем подтвердите каждое утверждение." + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -120573,6 +121584,12 @@ "state": "translated", "value": "Posty po zsynchronizowaniu paska i zdobyciu punktów NOOP w danym dniu — a nie dokładnie w chwili, gdy go przekroczysz. Najwyżej raz dziennie." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Появляется после синхронизации браслета и подсчёта оценок за день — не в ту самую секунду, когда вы пересекаете порог. Не чаще раза в день." + } } } }, @@ -120596,6 +121613,12 @@ "value": "Mode d'économie d'énergie" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Режим энергосбережения" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -123700,6 +124723,12 @@ "state": "translated", "value": "REGENERUJĄCY SEN" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ВОССТАНАВЛИВАЮЩИЙ СОН" + } } } }, @@ -125171,7 +126200,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Lesen Sie Ihre Herzfrequenz, HRV, Blutsauerstoff, Atemfrequenz, Schlaf, Schritte und Energie direkt von Apple Health und schreiben Sie NOOP's Banddaten zurück: Schlafen Sie mit vollen Stadien, kontinuierlicher Herzfrequenz, Workouts und nächtlichen Vitalen. Alles bleibt auf %1$@." + "value": "Lies deine Herzfrequenz, HRV, Blutsauerstoff, Atemfrequenz, Schlaf, Schritte und Energie direkt von Apple Health und schreibe NOOP's Banddaten zurück: Schlafe mit vollen Stadien, kontinuierlicher Herzfrequenz, Workouts und nächtlichen Vitalen. Alles bleibt auf %1$@." } }, "es": { @@ -125197,6 +126226,12 @@ "state": "translated", "value": "Odczytaj tętno, HRV, zawartość tlenu we krwi, częstość oddechów, sen, kroki i energię prosto z Apple Health i zapisz z powrotem dane paska NOOP: sen z pełnymi etapami, ciągłe tętno, treningi i nocne parametry życiowe. Wszystko pozostaje na %@." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Читайте пульс, ВСР, насыщение крови кислородом, частоту дыхания, сон, шаги и энергию прямо из Apple Health и записывайте данные браслета NOOP обратно: сон с полными стадиями, непрерывный пульс, тренировки и ночные показатели. Всё остаётся на %@." + } } } }, @@ -126259,6 +127294,12 @@ "state": "translated", "value": "Odczyty" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Измерения" + } } } }, @@ -126769,6 +127810,12 @@ "state": "translated", "value": "Zrestartuj sondę (4.0 RE)…" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проба перезагрузки (4.0 RE)…" + } } } }, @@ -128277,6 +129324,12 @@ "state": "translated", "value": "Trend Regeneracja" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Тренд восстановления" + } } } }, @@ -128782,6 +129835,12 @@ "state": "translated", "value": "Odśwież wiek fitness" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обновить фитнес-возраст" + } } } }, @@ -130395,6 +131454,12 @@ "value": "Renommer l'élément" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переименовать пункт" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -130435,6 +131500,12 @@ "value": "Renommer..." } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переименовать…" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -130692,7 +131763,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ersetzt die Registerkarte Heute durch das Prototyp-Redesign. Schalten Sie es jederzeit aus, um zum klassischen Dashboard zurückzukehren. Lies die gleichen live-daten von deinem strap." + "value": "Ersetzt die Registerkarte Heute durch das Prototyp-Redesign. Schalte es jederzeit aus, um zum klassischen Dashboard zurückzukehren. Lies die gleichen live-daten von deinem strap." } }, "es": { @@ -130718,6 +131789,12 @@ "state": "translated", "value": "Zastępuje zakładkę Dzisiaj przeprojektowanym prototypem. Wyłącz tę opcję w dowolnym momencie, aby powrócić do klasycznego pulpitu nawigacyjnego. Odczytuje te same dane w czasie rzeczywistym z Twojego paska." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Заменяет вкладку «Сегодня» прототипом нового дизайна. Выключите в любой момент, чтобы вернуться к классической панели. Читает те же живые данные с вашего браслета." + } } } }, @@ -130984,6 +132061,12 @@ "state": "translated", "value": "Alternatywy klasy badawczej / ulepszenia precyzyjne. Możliwość zapisania się, domyślnie wyłączona, niekliniczna." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Исследовательские альтернативы и точная подстройка. По желанию, по умолчанию выключено, не для клинического применения." + } } } }, @@ -132486,7 +133569,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "%1$@ neu starten? Es trennt sich für etwa 30 Sekunden, während es neu startet, und verbindet sich dann von selbst wieder. Ihre aufgezeichneten Daten werden aufbewahrt." + "value": "%1$@ neu starten? Es trennt sich für etwa 30 Sekunden, während es neu startet, und verbindet sich dann von selbst wieder. Deine aufgezeichneten Daten werden aufbewahrt." } }, "es": { @@ -132512,6 +133595,12 @@ "state": "translated", "value": "Uruchomić ponownie %@? Podczas ponownego uruchamiania rozłącza się na około 30 sekund, po czym samoczynnie łączy się ponownie. Twoje zapisane dane zostaną zachowane." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезапустить %@? Он отключится примерно на 30 секунд, пока перезагружается, затем подключится сам. Записанные данные сохраняются." + } } } }, @@ -132546,6 +133635,12 @@ "state": "translated", "value": "Uruchom ponownie pasek…" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезапустить браслет…" + } } } }, @@ -132554,7 +133649,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Starten Sie dieses Band neu?" + "value": "Starte dieses Band neu?" } }, "es": { @@ -132569,6 +133664,12 @@ "value": "Redémarrer cette sangle ?" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезапустить этот браслет?" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -132626,6 +133727,12 @@ "state": "translated", "value": "Uruchomiony ponownie po ponownej kalibracji %@ — nie trzeba go ponownie dotykać." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезапущено, когда вы откалибровали заново %@ — повторно нажимать не нужно." + } } } }, @@ -134134,6 +135241,12 @@ "state": "translated", "value": "Przeglądaj każdy punktowany blok budzenia pod kątem rzeczywistych dowodów na wstawanie (rytm chodu, zmiana pozycji ciała), a nie tylko wzrost tętna. Blokada bez ruchu i stabilna postawa – gorąca noc, krótki obrót – ponownie zapada w lekki sen; prawdziwe wstawanie zostaje pozostawione w spokoju. Samoczynnie sprawdza, ile szczegółów ruchu faktycznie zarejestrował Twój pasek, i pozostaje wyłączony w nocy, w której nie można ufać (głównie starsze oprogramowanie WHOOP 4.0). Domyślnie wyłączone; obowiązuje od kolejnych wystawianych nocy." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверяет каждый оценённый блок бодрствования на реальные признаки подъёма (ритм шагов, смена положения тела), а не только на рост пульса. Блок бодрствования без перемещения и с устойчивой позой — жаркая ночь, короткий переворот — возвращается в лёгкий сон; настоящий подъём остаётся как есть. Сам проверяет, сколько данных о движении действительно записал браслет, и остаётся выключенным в ночь, где их слишком мало, чтобы доверять (в основном старые прошивки WHOOP 4.0). По умолчанию выключено; вступает в силу для следующих размеченных ночей." + } } } }, @@ -135308,7 +136421,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Läuft den kontinuierlichen HRV-Stream nur während Ihres ruhigen Stundenfensters (standardmäßig 22:00-07:00 Uhr) aus und halbiert damit die Batteriekosten. Tagesstresswerte werden sparsamer sein. Hinweis: Die kontinuierliche Hintergrund-HRV-Erfassung (einschließlich Nickerchen am Tag) wird außerhalb dieses Fensters angehalten. Für On-Demand-HRV-Messungen am Tag (einschließlich Nickerchen), verwenden Sie die Schaltfläche \"Take an HRV reading\" auf dem Live-Bildschirm." + "value": "Läuft den kontinuierlichen HRV-Stream nur während deines ruhigen Stundenfensters (standardmäßig 22:00-07:00 Uhr) aus und halbiert damit die Batteriekosten. Tagesstresswerte werden sparsamer sein. Hinweis: Die kontinuierliche Hintergrund-HRV-Erfassung (einschließlich Nickerchen am Tag) wird außerhalb dieses Fensters angehalten. Für On-Demand-HRV-Messungen am Tag (einschließlich Nickerchen), verwende die Schaltfläche \"Take an HRV reading\" auf dem Live-Bildschirm." } }, "es": { @@ -135334,6 +136447,12 @@ "state": "translated", "value": "Uruchamia ciągły strumień HRV tylko w godzinach ciszy nocnej (domyślnie 22:00–07:00), co zmniejsza koszt baterii mniej więcej o połowę. Odczyty stresu w ciągu dnia będą rzadsze. Uwaga: ciągłe przechwytywanie HRV w tle (w tym drzemki w ciągu dnia) jest wstrzymywane poza tym oknem. W przypadku odczytów HRV na żądanie w ciągu dnia (w tym drzemek) użyj przycisku „Wykonaj odczyt HRV” na ekranie Na żywo." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Запускает непрерывный поток ВСР только в ваши тихие часы (по умолчанию 22:00–07:00), примерно вдвое снижая расход батареи. Дневные измерения стресса станут реже. Примечание: непрерывный фоновый сбор ВСР (включая дневной сон) вне этого окна приостановлен. Для дневных измерений ВСР по запросу (в том числе во время дневного сна) используйте кнопку «Сделать замер ВСР» на экране «В реальном времени»." + } } } }, @@ -139688,6 +140807,12 @@ "state": "translated", "value": "Wyślij sondę (tylko do odczytu)" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отправить пробу (только чтение)" + } } } }, @@ -139722,6 +140847,12 @@ "state": "translated", "value": "Wysyła GET_BODY_LOCATION_AND_STATUS (0x54) tylko do odczytu i pokazuje pełną nieprzetworzoną odpowiedź paska, dekodując zapis lokalizacji ciała (wersja/lokalizacja/pewność/status) w WHOOP 4.0. Nic nie jest zapisane na pasku i nigdy nie zmienia sposobu wykrywania zużycia ani punktacji." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отправляет команду только для чтения GET_BODY_LOCATION_AND_STATUS (0x54) и показывает полный сырой ответ браслета, декодируя запись о положении на теле (ревизия / положение / достоверность / статус) на WHOOP 4.0. На браслет ничего не записывается, и это никогда не меняет определение ношения или оценки." + } } } }, @@ -141586,6 +142717,12 @@ "value": "Afficher une carte Aujourd'hui vous rappelant de remplir votre journal" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывать карточку на «Сегодня» с напоминанием заполнить журнал" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -142391,6 +143528,12 @@ "state": "translated", "value": "Trening pasków polegający na ciszy podczas treningów." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подсказки браслета во время тренировок — по умолчанию молча." + } } } }, @@ -145029,6 +146172,12 @@ "value": "Stade de sommeil (V2)" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Определение стадий сна (V2)" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -145254,6 +146403,12 @@ "state": "translated", "value": "Tętno podczas snu w nocy" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пульс во сне в течение ночи" + } } } }, @@ -145515,6 +146670,12 @@ "value": "Lentifie le sangle de fond (toutes les 45 min au lieu de 15) tandis que la batterie de votre sangle est basse. Aucune perte de données — la sangle accumule tout, donc synchronise juste les lots dans des tirages plus grands et moins fréquents." } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Замедляет фоновую синхронизацию с браслетом (каждые 45 минут вместо 15), пока заряд браслета низкий. Потери данных нет — браслет всё накапливает, поэтому синхронизация просто идёт более крупными и редкими порциями." + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -146739,6 +147900,12 @@ "value": "Tuiles carrées avec un graphique de tendance sous la barre." } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Более квадратные плитки с графиком тренда под полосой." + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -150220,6 +151387,12 @@ "state": "translated", "value": "Cel Obciążenie" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Целевая нагрузка" + } } } }, @@ -151624,6 +152797,12 @@ "state": "translated", "value": "Pasemko" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Серия" + } } } }, @@ -154001,7 +155180,135 @@ } } }, - "Syncing strap history…": { + "Pending sync": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Sync ausstehend" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Pending sync" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sincronización pendiente" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Synchronisation en attente" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sincronizzazione in sospeso" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Oczekuje na synchronizację" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Sincronização pendente" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ожидает синхронизации" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "等待同步" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "等待同步" + } + } + } + }, + "strap history still offloading": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Strap-Verlauf wird noch übertragen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "strap history still offloading" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "el historial de la pulsera aún se está transfiriendo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "l'historique du bracelet est encore en cours de transfert" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "la cronologia della fascia è ancora in trasferimento" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "historia paska wciąż jest przesyłana" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "o histórico da bracelete ainda está a ser transferido" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "история браслета ещё передаётся" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "手环历史记录仍在传输" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "手環歷史記錄仍在傳輸" + } + } + } + }, + "Syncing strap history…": { "localizations": { "de": { "stringUnit": { @@ -155488,6 +156795,12 @@ "state": "translated", "value": "Kliknij dzień, aby nadrobić zaległości" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нажмите на день, чтобы наверстать" + } } } }, @@ -155554,7 +156867,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Tippen Sie auf eine Bühne, um mit Ihrem 30-Tage-Typ zu vergleichen." + "value": "Tippe auf eine Bühne, um mit deinem 30-Tage-Typ zu vergleichen." } }, "es": { @@ -155580,6 +156893,12 @@ "state": "translated", "value": "Kliknij etap, aby porównać go z typowym 30-dniowym." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нажмите на стадию, чтобы сравнить с вашим типичным значением за 30 дней." + } } } }, @@ -155989,6 +157308,64 @@ } } }, + "Skin temperature display": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Hauttemperatur-Anzeige" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Visualización de temperatura cutánea" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Affichage de la température cutanée" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Visualizzazione temperatura cutanea" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wyświetlanie temperatury skóry" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Apresentação da temperatura da pele" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отображение температуры кожи" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "皮肤温度显示" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "皮膚溫度顯示" + } + } + } + }, "Temperature unit": { "localizations": { "de": { @@ -156592,7 +157969,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Deshalb beginnt eine Watch-Only-Energie mit \"Kalibrieren\". NOOP benötigt etwa sieben Nächte Ihrer HRV, um zu erfahren, wie normal für Sie aussieht. Bis es sie hat, hält es die Punktzahl zurück, anstatt zu erraten. Sobald die Basislinie festgelegt ist, erscheint Ihre Energie mit ihrem Vertrauen auf der gleichen 0-100-Skala wie ein Gurt." + "value": "Deshalb beginnt eine Watch-Only-Energie mit \"Kalibrieren\". NOOP benötigt etwa sieben Nächte deiner HRV, um zu erfahren, wie normal für dich aussieht. Bis es sie hat, hält es die Punktzahl zurück, anstatt zu erraten. Sobald die Basislinie festgelegt ist, erscheint deine Energie mit ihrem Vertrauen auf der gleichen 0-100-Skala wie ein Gurt." } }, "es": { @@ -156618,6 +157995,12 @@ "state": "translated", "value": "Dlatego Energia przeznaczony tylko do oglądania rozpoczyna się od „Kalibracji”. NOOP potrzebuje około siedmiu nocy Twojego HRV, aby dowiedzieć się, jak wygląda normalność w Twoim przypadku. Dopóki ich nie ma, raczej wstrzymuje wynik niż zgaduje. Po ustawieniu linii bazowej Twój Energia pojawi się pewnie, w tej samej skali 0-100, co pasek." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Поэтому Заряд, построенный только по часам, сначала показывает «Калибровку». NOOP нужно около семи ночей вашей ВСР, чтобы понять, что для вас нормально. Пока их нет, он не показывает оценку, вместо того чтобы гадать. Как только норма определена, ваш Заряд появляется вместе с его достоверностью, по той же шкале 0–100, что и у браслета." + } } } }, @@ -156742,7 +158125,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Der WHOOP 4.0-Reboot-Frame wird nicht bestätigt - ein normaler Neustart wird ignoriert (# 235). Senden sie jedem kandidaten und beobachten sie beide das straplog und das strap selbst. \"Keine Trennung innerhalb von 12s\" bedeutet, dass das Band den Rahmen ignoriert hat. Eine \"Link Drop\" -Linie bedeutet, dass der Rahmen den Riemen erreicht hat - aber ein Drop Link allein ist kein Neustart: Ein echter Neustart schaltet auch das Sensorlicht des Riemens für ein paar Sekunden aus, also wenn das Licht anblieb, war es nur eine Drop-Verbindung, kein Neustart. Zerstörungsfrei - Ihre Daten werden gespeichert. Bitte teilen Sie das Protokoll, damit wir den echten Rahmen anheften können." + "value": "Der WHOOP 4.0-Reboot-Frame wird nicht bestätigt - ein normaler Neustart wird ignoriert (# 235). Sende sie an jeden Kandidaten und beobachte dabei sowohl das Straplog als auch den Strap selbst. \"Keine Trennung innerhalb von 12s\" bedeutet, dass das Band den Rahmen ignoriert hat. Eine \"Link Drop\" -Linie bedeutet, dass der Rahmen den Riemen erreicht hat - aber ein Drop Link allein ist kein Neustart: Ein echter Neustart schaltet auch das Sensorlicht des Riemens für ein paar Sekunden aus, also wenn das Licht anblieb, war es nur eine Drop-Verbindung, kein Neustart. Zerstörungsfrei - deine Daten werden gespeichert. Bitte teile das Protokoll, damit wir den echten Rahmen anheften können." } }, "es": { @@ -156768,6 +158151,12 @@ "state": "translated", "value": "Ramka ponownego uruchomienia WHOOP 4.0 nie została potwierdzona — normalne ponowne uruchomienie jest ignorowane (#235). Wyślij każdego kandydata i obejrzyj ZARÓWNO dziennik paska, jak i sam pasek. „brak rozłączenia w ciągu 12 s” oznacza, że ​​pasek zignorował ramę. Linia „opuszczone ogniwo” oznacza, że ​​rama dotarła do paska — ale samo upuszczone ogniwo nie oznacza ponownego uruchomienia: prawdziwe ponowne uruchomienie powoduje również wyłączenie na kilka sekund lampki czujnika paska, zatem jeśli lampka pozostanie włączona, oznacza to po prostu zerwanie połączenia, a nie ponowne uruchomienie. Nieniszczące — Twoje dane są przechowywane. Udostępnij log, abyśmy mogli przypiąć prawdziwą ramkę." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Кадр перезагрузки WHOOP 4.0 не подтверждён — обычный перезапуск игнорируется (#235). Отправляйте каждый вариант и следите И за журналом браслета, И за самим браслетом. «нет отключения за 12 с» означает, что браслет проигнорировал кадр. Строка «связь потеряна» означает, что кадр дошёл до браслета, — но одна лишь потеря связи ещё не перезагрузка: при настоящей перезагрузке датчик браслета также гаснет на несколько секунд, поэтому если подсветка не погасла, это был просто обрыв соединения, а не перезагрузка. Ничего не разрушается — ваши данные сохраняются. Пожалуйста, поделитесь журналом, чтобы мы могли определить настоящий кадр." + } } } }, @@ -157034,6 +158423,12 @@ "state": "translated", "value": "Zmiana tętna pod koniec ćwiczeń. Wartości dodatnie oznaczają spadek tętna; myślnik oznacza, że ​​w tej minucie pasek nie zarejestrował wystarczającej ilości danych." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Изменение относительно вашего пульса в конце нагрузки. Положительные значения означают, что пульс снизился; прочерк означает, что браслет не записал достаточно данных около этой минуты." + } } } }, @@ -157158,7 +158553,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Die Linie ist der 0-3 Proxy jeder Wachstunde, der heute gegen Ihre eigenen ruhigen Stunden erzielt wurde. Die Bar unten teilt Ihren Tag in ruhige, moderate und hohe Stresszeit." + "value": "Die Linie ist der 0-3 Proxy jeder Wachstunde, der heute gegen deine eigenen ruhigen Stunden erzielt wurde. Die Bar unten teilt deinen Tag in ruhige, moderate und hohe Stresszeit." } }, "es": { @@ -157184,6 +158579,12 @@ "state": "translated", "value": "Linia to przybliżony wskaźnik 0-3 każdej godziny czuwania, porównany z Twoimi dzisiejszymi spokojnymi godzinami. Poniższy pasek dzieli Twój dzień na czas spokoju, umiarkowanego i dużego stresu." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Линия — это показатель 0–3 за каждый час бодрствования, оценённый относительно ваших собственных спокойных часов сегодня. Полоса ниже делит ваш день на время спокойного, умеренного и высокого стресса." + } } } }, @@ -157662,7 +159063,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Der Riemen meldet eine andere Zeit als NOOP sendet, so dass sein Firmware-Alarm nicht zu Ihrer Weckzeit feuert - normalerweise ein Riemen, dessen Uhr oder Alarm zurückgesetzt wurde. Setzen Sie den Riemen in der offiziellen WHOOP-App zurück (oder laden Sie ihn vollständig auf und verbinden Sie ihn wieder), und halten Sie den Uhralarm Ihres Telefons solange wach, bis es dauert." + "value": "Der Riemen meldet eine andere Zeit als NOOP sendet, so dass sein Firmware-Alarm nicht zu deiner Weckzeit feuert - normalerweise ein Riemen, dessen Uhr oder Alarm zurückgesetzt wurde. Setze den Riemen in der offiziellen WHOOP-App zurück (oder lade ihn vollständig auf und verbinde ihn wieder), und halte den Uhralarm deines Telefons solange wach, bis es dauert." } }, "es": { @@ -157688,6 +159089,12 @@ "state": "translated", "value": "Pasek zgłasza inny czas niż wysyła NOOP, więc alarm oprogramowania sprzętowego nie włączy się o godzinie pobudki — zwykle jest to pasek, którego zegar lub alarm został zresetowany. Zresetuj pasek w oficjalnej aplikacji WHOOP (lub naładuj go do pełna i podłącz ponownie), a budzik w telefonie będzie włączał się do momentu, aż to nastąpi." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Браслет продолжает сообщать время, отличное от отправленного NOOP, поэтому его встроенный будильник не сработает в час вашего пробуждения — обычно это браслет, у которого сбросились часы или будильник. Сбросьте браслет в официальном приложении WHOOP (или полностью зарядите его и подключите заново), а до тех пор используйте будильник в «Часах» телефона." + } } } }, @@ -158573,7 +159980,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Auch diese Sicherungen sind unverschlüsselt. Wenn dieser Ordner mit Drive, Dropbox oder iCloud synchronisiert wird, landet die lesbare Datei auch dort — zeigen Sie nur auf einen Dienst, dem Sie vertrauen." + "value": "Auch diese Sicherungen sind unverschlüsselt. Wenn dieser Ordner mit Drive, Dropbox oder iCloud synchronisiert wird, landet die lesbare Datei auch dort — zeige nur auf einen Dienst, dem du vertraust." } }, "en": { @@ -159296,7 +160703,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Das ist ein einfaches, unverschlüsseltes Archiv — wer die Datei bekommt, kann sie mit jedem Zip-Programm öffnen. Bewahren Sie sie an einem Ort auf, dem Sie vertrauen." + "value": "Das ist ein einfaches, unverschlüsseltes Archiv — wer die Datei bekommt, kann sie mit jedem Zip-Programm öffnen. Bewahre sie an einem Ort auf, dem du vertraust." } }, "en": { @@ -159910,6 +161317,12 @@ "value": "Cela déplace la nuit à une heure sans données enregistrées. Les étapes ne peuvent pas être dérivées là, donc il peut apparaître comme vide jusqu'à ce que les données le couvrent." } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Это перенесёт ночь на время, где нет записанных данных. Стадии там вывести нельзя, поэтому она может отображаться пустой, пока данные её не покроют." + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -163902,6 +165315,12 @@ "value": "Tableaux de tendances" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Графики трендов" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -164011,6 +165430,12 @@ "state": "translated", "value": "Okno trendu" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Окно тренда" + } } } }, @@ -164855,7 +166280,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Schalten Sie Benachrichtigungen für NOOP in den Einstellungen ein, um Ihre Winddown-Erinnerung zu erhalten." + "value": "Schalte Benachrichtigungen für NOOP in den Einstellungen ein, um deine Winddown-Erinnerung zu erhalten." } }, "es": { @@ -164881,6 +166306,12 @@ "state": "translated", "value": "Włącz powiadomienia dla NOOP w Ustawieniach, aby otrzymać przypomnienie o odpoczynku." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включите уведомления для NOOP в Настройках, чтобы получать напоминание о подготовке ко сну." + } } } }, @@ -164973,6 +166404,12 @@ "state": "translated", "value": "Dwie niezależne tabele protokołów nie zgadzają się co do kodu operacji o rozszerzonej baterii (98 vs 87). Spowoduje to wysłanie wybranego numeru 98 w trybie tylko do odczytu i wyświetlenie pełnej nieprzetworzonej odpowiedzi paska. Ładunek przypominający baterię potwierdza 98 w oprogramowaniu sprzętowym; krótki odcinek oznacza, że ​​pozostaje niejednoznaczny. Na pasku nic nie jest zapisane." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Две независимые таблицы протокола расходятся в коде расширенных данных о батарее (98 против 87). Здесь отправляется проверенная команда только для чтения 98 и показывается полный сырой ответ браслета. Ответ в формате батареи подтверждает 98 на вашей прошивке; короткая заглушка означает, что вопрос остаётся открытым. На браслет ничего не записывается." + } } } }, @@ -165228,6 +166665,12 @@ "value": "Annuler" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отменить" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -168030,6 +169473,12 @@ "value": "Sonde de redémarrage WHOOP 4.0" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проба перезагрузки WHOOP 4.0" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -169828,6 +171277,12 @@ "state": "translated", "value": "Czekam na odpowiedź paska…" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ожидание ответа браслета…" + } } } }, @@ -170944,7 +172399,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Wir vergleichen die heutige Ruheherzfrequenz und HRV mit Ihrer eigenen 30-Tage-Baseline. Eine höher als üblich ruhende HR und eine niedriger als üblich HRV schieben beide die Punktzahl nach oben, klassische Zeichen, dass der Körper aktiviert wird. Die kombinierte Schicht wird auf eine 0-3 Skala abgebildet: 0 ist ruhig, 1,5 sitzt an der Basislinie, 3 ist stark aktiviert." + "value": "Wir vergleichen die heutige Ruheherzfrequenz und HRV mit deiner eigenen 30-Tage-Baseline. Eine höher als üblich ruhende HR und eine niedriger als üblich HRV schieben beide die Punktzahl nach oben, klassische Zeichen, dass der Körper aktiviert wird. Die kombinierte Schicht wird auf eine 0-3 Skala abgebildet: 0 ist ruhig, 1,5 sitzt an der Basislinie, 3 ist stark aktiviert." } }, "es": { @@ -170970,6 +172425,12 @@ "state": "translated", "value": "Porównujemy dzisiejsze tętno spoczynkowe i HRV z Twoją własną wartością bazową z 30 dni. Wyższe niż zwykle tętno spoczynkowe i niższe niż zwykle HRV podnoszą wynik, co jest klasycznym sygnałem, że organizm jest pobudzony. Połączone przesunięcie jest odwzorowywane na skali 0-3: 0 oznacza spokój, 1,5 znajduje się na poziomie podstawowym, 3 jest wysoce aktywowany." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Мы сравниваем сегодняшние пульс в покое и ВСР с вашей собственной 30-дневной нормой. Более высокий, чем обычно, пульс в покое и более низкая, чем обычно, ВСР — оба повышают оценку: классические признаки активации организма. Совокупный сдвиг отображается на шкалу 0–3: 0 — спокойствие, 1,5 — ваша норма, 3 — сильная активация." + } } } }, @@ -173682,7 +175143,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Wenn NOOP die Herzfrequenz aus der optischen Wellenform WHOOP 5/MG v26 rekonstruiert (die Sekunden, in denen das Band keine HR gespeichert hat), verfeinern Sie den Autokorrelationspeak mit einem parabolischen Sublag-Fit, so dass die Schätzung nicht auf etwa 16 bpm-Schritte in der Nähe einer hohen HR quantifiziert wird. Es füllt nur Sekunden, die das Band nie gemeldet hat; es überschreibt niemals eine gespeicherte HR. 5/MG, standardmäßig ausgeschaltet." + "value": "Wenn NOOP die Herzfrequenz aus der optischen Wellenform WHOOP 5/MG v26 rekonstruiert (die Sekunden, in denen das Band keine HR gespeichert hat), verfeinere den Autokorrelationspeak mit einem parabolischen Sublag-Fit, so dass die Schätzung nicht auf etwa 16 bpm-Schritte in der Nähe einer hohen HR quantifiziert wird. Es füllt nur Sekunden, die das Band nie gemeldet hat; es überschreibt niemals eine gespeicherte HR. 5/MG, standardmäßig ausgeschaltet." } }, "es": { @@ -173708,6 +175169,12 @@ "state": "translated", "value": "Kiedy NOOP rekonstruuje tętno na podstawie fali optycznej WHOOP 5/MG v26 (sekundy, w których pasek nie zapisuje HR), doprecyzuj pik autokorelacji za pomocą parabolicznego dopasowania z sub-opóźnieniem, aby oszacowanie nie było kwantyzowane do około 16 kroków bpm w pobliżu wysokiego HR. Wypełnia tylko sekundy, o których pasek nigdy nie raportował; nigdy nie zastępuje zapisanego HR. Tylko 5/MG, domyślnie wyłączone." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Когда NOOP восстанавливает пульс по оптическому сигналу v26 WHOOP 5/MG (за секунды, для которых браслет не сохранил пульс), уточнять пик автокорреляции параболической подгонкой внутри задержки, чтобы оценка не квантовалась шагами примерно по 16 уд/мин при высоком пульсе. Заполняются только секунды, которые браслет никогда не передавал; сохранённый пульс никогда не перезаписывается. Только 5/MG, по умолчанию выключено." + } } } }, @@ -174562,6 +176029,12 @@ "value": "Quand la batterie de votre bracelet est basse, arrêtez le flux de VFC en arrière-plan, ce qui sollicite le plus votre bracelet. Un écran Live affiche toujours la fréquence cardiaque, et il recommence automatiquement une fois le bracelet chargé." } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пока заряд браслета низкий, останавливать постоянный фоновый поток ВСР — самый большой непрерывный расход для браслета. Экран «В реальном времени» по-прежнему показывает пульс, а поток включится сам, как только браслет зарядится." + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -174587,7 +176060,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Die ganze Nacht ist das Standardmaß von NOOP; Deep Sleep Pools HRV nur über Slow Wave Sleep, lesen niedriger und passend zu WHOOP. Durch das Umschalten werden Ihre letzten Nächte über das neue Fenster neu bewertet und sofort wirksam, sobald Sie ein paar Nächte Daten haben." + "value": "Die ganze Nacht ist das Standardmaß von NOOP; Deep Sleep Pools HRV nur über Slow Wave Sleep, lesen niedriger und passend zu WHOOP. Durch das Umschalten werden deine letzten Nächte über das neue Fenster neu bewertet und sofort wirksam, sobald du ein paar Nächte Daten hast." } }, "es": { @@ -174602,6 +176075,12 @@ "value": "Toute la nuit est la mesure par défaut de NOOP; Le sommeil profond met en commun la VFC sur le sommeil à ondes lentes uniquement, en lisant plus bas et correspondant à WHOOP. Un changement recalcule vos dernières nuits sur la nouvelle fenêtre et prend effet dès que vous avez quelques nuits de données." } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вся ночь — мера NOOP по умолчанию; «Глубокий сон» собирает ВСР только по медленноволновому сну, даёт более низкие значения и совпадает с WHOOP. Переключение пересчитает ваши недавние ночи по новому окну и вступит в силу сразу, как только накопится несколько ночей данных." + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -178038,6 +179517,12 @@ "state": "translated", "value": "Osiągnąłeś dzisiejsze optymalne docelowe odkształcenie %lld. Niezła robota – zasłużyłeś na powrót do zdrowia." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вы достигли сегодняшней цели по нагрузке — %lld. Отличная работа, ваше восстановление это заслужило." + } } } }, @@ -180663,6 +182148,12 @@ "state": "translated", "value": "Twój pasek nie akceptuje alarmu" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ваш браслет не принимает будильник" + } } } }, @@ -181185,6 +182676,12 @@ "state": "translated", "value": "Strefa %lld: %lld-%lld bpm (%lld-%lld%% max HR)" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Зона %lld: %lld–%lld уд/мин (%lld–%lld%% от макс. пульса)" + } } } }, @@ -183628,6 +185125,12 @@ "state": "translated", "value": "dzień z rzędu" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "день подряд" + } } } }, @@ -183668,6 +185171,12 @@ "state": "translated", "value": "dni z rzędu" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "дней подряд" + } } } }, @@ -191947,6 +193456,12 @@ "state": "translated", "value": "zazwyczaj %@" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "обычно %@" + } } } }, @@ -192451,6 +193966,12 @@ "state": "translated", "value": "Historia v%lld" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "история v%lld" + } } } }, @@ -194950,6 +196471,262 @@ } } }, + "12-hour": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "12 Stunden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "12-hour" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "12 horas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "12 heures" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "12 ore" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "12-godzinny" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "12 horas" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "12-часовой" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "12 小时制" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "12 小時制" + } + } + } + }, + "24-hour": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "24 Stunden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "24-hour" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "24 horas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "24 heures" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "24 ore" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "24-godzinny" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "24 horas" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "24-часовой" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "24 小时制" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "24 小時制" + } + } + } + }, + "Clock": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Uhr" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Clock" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reloj" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Horloge" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Orologio" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zegar" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Relógio" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Часы" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "时钟" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "時鐘" + } + } + } + }, + "Hide bar when scrolling": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Leiste beim Scrollen ausblenden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Hide bar when scrolling" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ocultar la barra al desplazarse" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Masquer la barre pendant le défilement" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nascondi la barra durante lo scorrimento" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ukryj pasek podczas przewijania" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Ocultar a barra ao deslocar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скрывать панель при прокрутке" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "滚动时隐藏底部栏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "捲動時隱藏底部列" + } + } + } + }, "System default": { "localizations": { "de": { @@ -195426,6 +197203,122 @@ } } }, + "Only %lld%% of this night's window has stage data. The stage totals cover only that part of the night.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur %lld%% des Zeitfensters dieser Nacht enthält Schlafphasendaten. Die Phasenwerte decken nur diesen Teil der Nacht ab." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Solo el %lld%% de la ventana de esta noche tiene datos de fases. Los totales de fases cubren solo esa parte de la noche." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Seuls %lld%% de la fenêtre de cette nuit comportent des données de phases. Les totaux de phases ne couvrent que cette partie de la nuit." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Solo il %lld%% della finestra di questa notte ha dati sulle fasi. I totali delle fasi coprono solo quella parte della notte." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Apenas %lld%% da janela desta noite tem dados de fases. Os totais de fases cobrem apenas essa parte da noite." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Только %lld%% окна этой ночи содержит данные о фазах. Итоги по фазам охватывают только эту часть ночи." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这一夜的时间窗口中只有 %lld%% 有睡眠阶段数据。阶段合计仅涵盖该部分时间。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "這一夜的時間視窗中只有 %lld%% 有睡眠階段資料。階段合計僅涵蓋該部分時間。" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Tylko %lld%% okna tej nocy ma dane o fazach. Sumy faz obejmują tylko tę część nocy." + } + } + } + }, + "Partly recorded": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Teilweise aufgezeichnet" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Registro parcial" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Enregistrement partiel" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Registrazione parziale" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Registo parcial" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Записано частично" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "部分记录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "部分記錄" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Częściowo zarejestrowane" + } + } + } + }, "Customize": { "localizations": { "de": { @@ -207265,6 +209158,12 @@ "state": "translated", "value": "Obciążenie treningowe" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Тренировочная нагрузка" + } } } }, @@ -207299,6 +209198,12 @@ "state": "translated", "value": "CTL · Kondycja" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "CTL · Форма" + } } } }, @@ -207333,6 +209238,12 @@ "state": "translated", "value": "ATL · Zmęczenie" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ATL · Усталость" + } } } }, @@ -207367,6 +209278,12 @@ "state": "translated", "value": "Kondycja 42-dniowa vs zmęczenie 7-dniowe" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "42-дневная форма против 7-дневной усталости" + } } } }, @@ -207401,6 +209318,12 @@ "state": "translated", "value": "Budowanie — %1$lld z %2$lld dni" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Формируется — %lld из %lld дней" + } } } }, @@ -207435,6 +209358,12 @@ "state": "translated", "value": "Obciążenie przewlekłe vs ostre" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Хроническая против острой нагрузки" + } } } }, @@ -207469,6 +209398,12 @@ "state": "translated", "value": "Wymaga %1$lld+ kolejnych dni Wysiłku, aby zacząć. Na razie %2$lld." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нужно %lld+ подряд дней с Усилием, чтобы начать. Пока %lld." + } } } }, @@ -207503,6 +209438,12 @@ "state": "translated", "value": "Forma" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Форма" + } } } }, @@ -207537,6 +209478,12 @@ "state": "translated", "value": "Obciążenie treningowe: przewlekłe vs ostre" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Тренировочная нагрузка: хроническая против острой" + } } } }, @@ -207571,6 +209518,12 @@ "state": "translated", "value": "Rzadsze odświeżanie" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Редкое обновление" + } } } }, @@ -207605,6 +209558,12 @@ "state": "translated", "value": "Synchronizuje w tle co godzinę zamiast co 15 minut, niezależnie od poziomu naładowania opaski — mniej ponownych połączeń to największa oszczędność w WHOOP 4.0. Nic nie ginie: opaska zapisuje wszystko i przekazuje w większych paczkach. Ręczna synchronizacja nadal działa od razu, a tętno na żywo pozostaje bez zmian." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Синхронизировать в фоне раз в час вместо каждых 15 минут, независимо от заряда браслета — меньше переподключений даёт наибольшую экономию на WHOOP 4.0. Ничего не теряется: браслет всё накапливает и отдаёт большими порциями. Синхронизация жестом по-прежнему работает сразу, а живой пульс не затрагивается." + } } } }, @@ -207639,6 +209598,12 @@ "state": "translated", "value": "Oszczędzanie energii" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Энергосбережение" + } } } }, @@ -207673,6 +209638,12 @@ "state": "translated", "value": "Odciąż opaskę, gdy jej bateria się kończy." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Снизьте нагрузку на браслет, когда его заряд на исходе." + } } } }, @@ -207707,6 +209678,12 @@ "state": "translated", "value": "Opaska nadal zapisuje dane samodzielnie, więc nic nie ginie — NOOP po prostu rzadziej się z nią komunikuje, aby wystarczyła do naładowania." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Браслет продолжает накапливать данные сам, поэтому ничего не теряется — NOOP просто реже с ним общается, чтобы тот дожил до зарядки." + } } } }, @@ -207834,7 +209811,428 @@ } }, "A cardiovascular load from time in heart-rate zones (Edwards TRIMP): each zone is weighted so harder ones count for more, summed into one daily figure. Settings offers an exponential alternative (Banister) that credits short, hard efforts more.": { "localizations": { "de": { "stringUnit": { "state": "translated", "value": "Eine kardiovaskuläre Last aus der Zeit in den Herzfrequenzzonen (Edwards-TRIMP): Jede Zone wird gewichtet, sodass härtere Zonen mehr zählen, aufsummiert zu einer Tageszahl. In den Einstellungen gibt es eine exponentielle Alternative (Banister), die kurze, harte Belastungen stärker anrechnet." } }, "en": { "stringUnit": { "state": "translated", "value": "A cardiovascular load from time in heart-rate zones (Edwards TRIMP): each zone is weighted so harder ones count for more, summed into one daily figure. Settings offers an exponential alternative (Banister) that credits short, hard efforts more." } }, "es": { "stringUnit": { "state": "translated", "value": "Una carga cardiovascular a partir del tiempo en las zonas de frecuencia cardíaca (TRIMP de Edwards): cada zona se pondera para que las más duras cuenten más, sumado en una sola cifra diaria. En Ajustes hay una alternativa exponencial (Banister) que valora más los esfuerzos cortos e intensos." } }, "fr": { "stringUnit": { "state": "translated", "value": "Une charge cardiovasculaire issue du temps passé dans les zones de fréquence cardiaque (TRIMP d’Edwards) : chaque zone est pondérée pour que les plus intenses comptent davantage, additionnée en un seul chiffre journalier. Les Réglages proposent une alternative exponentielle (Banister) qui valorise davantage les efforts courts et intenses." } }, "it": { "stringUnit": { "state": "translated", "value": "Un carico cardiovascolare dato dal tempo nelle zone di frequenza cardiaca (TRIMP di Edwards): ogni zona è ponderata in modo che quelle più dure contino di più, sommato in un unico dato giornaliero. Nelle Impostazioni c’è un’alternativa esponenziale (Banister) che premia di più gli sforzi brevi e intensi." } }, "pl": { "stringUnit": { "state": "translated", "value": "Obciążenie układu krążenia wynikające z czasu w strefach tętna (TRIMP Edwardsa): każda strefa jest ważona tak, aby trudniejsze liczyły się bardziej, zsumowane w jedną wartość dzienną. W Ustawieniach jest alternatywa wykładnicza (Banister), która mocniej docenia krótkie, intensywne wysiłki." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Uma carga cardiovascular a partir do tempo nas zonas de frequência cardíaca (TRIMP de Edwards): cada zona é ponderada para que as mais difíceis contem mais, resumido num valor diário. Nas Definições existe uma alternativa exponencial (Banister) que valoriza mais os esforços curtos e intensos." } }, "ru": { "stringUnit": { "state": "translated", "value": "Сердечно-сосудистая нагрузка по времени в пульсовых зонах (TRIMP Эдвардса): каждая зона взвешивается так, что более тяжёлые учитываются сильнее, и всё суммируется в одно дневное значение. В настройках есть экспоненциальная альтернатива (Banister), которая сильнее засчитывает короткие интенсивные усилия." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "根据你在各心率区间的时长得出的心血管负荷(Edwards TRIMP):区间越高权重越大,汇总为一个每日数字。设置中提供指数式替代方案(Banister),会更看重短时高强度的努力。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "根據你在各心率區間停留時間得出的心血管負荷(Edwards TRIMP):區間越高權重越大,加總成每日一個數字。設定中提供指數式替代方案(Banister),會更看重短時高強度的努力。" } } + } }, + "WHOOP 5.0 / MG supports live data and strap history. Protocol-research tools are available separately in Test Centre.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG unterstützt Live-Daten und Bandverlauf. Protokollwerkzeuge sind separat im Testcenter."}},"es":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG admite datos en directo e historial. Las herramientas de protocolo están aparte en el Centro de pruebas."}},"fr":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG gère les données en direct et l’historique. Les outils de protocole sont séparés dans le Centre de test."}},"pt-PT":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG suporta dados em direto e histórico. As ferramentas de protocolo estão separadas no Centro de testes."}},"it":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG supporta i dati in tempo reale e la cronologia della fascia. Gli strumenti di ricerca sul protocollo sono separati nel Centro test."}},"pl":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG obsługuje dane na żywo i historię paska. Narzędzia badawcze protokołu są dostępne osobno w Centrum Testowym."}},"ru":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG поддерживает данные в реальном времени и историю браслета. Инструменты исследования протокола вынесены в Тестовый центр."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG 支持实时数据和手环历史。协议研究工具单独位于测试中心。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG 支援即時資料與手環歷史。協定研究工具另置於測試中心。"}}}}, + "Delete this session?": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Diese Session löschen?"}},"es":{"stringUnit":{"state":"translated","value":"¿Eliminar esta sesión?"}},"fr":{"stringUnit":{"state":"translated","value":"Supprimer cette session ?"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Eliminar esta sessão?"}},"it":{"stringUnit":{"state":"translated","value":"Eliminare questa sessione?"}},"pl":{"stringUnit":{"state":"translated","value":"Usunąć tę sesję?"}},"ru":{"stringUnit":{"state":"translated","value":"Удалить эту сессию?"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"要删除此会话吗?"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"要刪除此工作階段嗎?"}}}}, + "The session %@ and its captured raw data will be deleted permanently.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Die Session %@ und ihre Rohdaten werden dauerhaft gelöscht."}},"es":{"stringUnit":{"state":"translated","value":"La sesión %@ y sus datos se eliminarán permanentemente."}},"fr":{"stringUnit":{"state":"translated","value":"La session %@ et ses données seront définitivement supprimées."}},"pt-PT":{"stringUnit":{"state":"translated","value":"A sessão %@ e os dados serão eliminados permanentemente."}},"it":{"stringUnit":{"state":"translated","value":"La sessione %@ e i suoi dati grezzi verranno eliminati definitivamente."}},"pl":{"stringUnit":{"state":"translated","value":"Sesja %@ i jej surowe dane zostaną trwale usunięte."}},"ru":{"stringUnit":{"state":"translated","value":"Сессия %@ и её сырые данные будут удалены безвозвратно."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"会话 %@ 及其采集的原始数据将被永久删除。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"工作階段 %@ 及其擷取的原始資料將被永久刪除。"}}}}, + "Delete all sessions?": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Alle Sessions löschen?"}},"es":{"stringUnit":{"state":"translated","value":"¿Eliminar todas las sesiones?"}},"fr":{"stringUnit":{"state":"translated","value":"Supprimer toutes les sessions ?"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Eliminar todas as sessões?"}},"it":{"stringUnit":{"state":"translated","value":"Eliminare tutte le sessioni?"}},"pl":{"stringUnit":{"state":"translated","value":"Usunąć wszystkie sesje?"}},"ru":{"stringUnit":{"state":"translated","value":"Удалить все сессии?"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"要删除所有会话吗?"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"要刪除所有工作階段嗎?"}}}}, + "All %lld recorded sessions and their captured raw data will be deleted permanently.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Alle %lld Sessions und ihre Rohdaten werden dauerhaft gelöscht."}},"es":{"stringUnit":{"state":"translated","value":"Las %lld sesiones y sus datos se eliminarán permanentemente."}},"fr":{"stringUnit":{"state":"translated","value":"Les %lld sessions et leurs données seront définitivement supprimées."}},"pt-PT":{"stringUnit":{"state":"translated","value":"As %lld sessões e os dados serão eliminados permanentemente."}},"it":{"stringUnit":{"state":"translated","value":"Tutte le %lld sessioni registrate e i loro dati grezzi verranno eliminati definitivamente."}},"pl":{"stringUnit":{"state":"translated","value":"Wszystkie zarejestrowane sesje (%lld) i ich surowe dane zostaną trwale usunięte."}},"ru":{"stringUnit":{"state":"translated","value":"Все записанные сессии (%lld) и их сырые данные будут удалены безвозвратно."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"全部 %lld 个已记录会话及其采集的原始数据将被永久删除。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"全部 %lld 個已記錄工作階段及其擷取的原始資料將被永久刪除。"}}}}, + "Couldn't export session": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Session konnte nicht exportiert werden"}},"es":{"stringUnit":{"state":"translated","value":"No se pudo exportar la sesión"}},"fr":{"stringUnit":{"state":"translated","value":"Impossible d’exporter la session"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Não foi possível exportar a sessão"}},"it":{"stringUnit":{"state":"translated","value":"Impossibile esportare la sessione"}},"pl":{"stringUnit":{"state":"translated","value":"Nie można wyeksportować sesji"}},"ru":{"stringUnit":{"state":"translated","value":"Не удалось экспортировать сессию"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"无法导出会话"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"無法匯出工作階段"}}}}, + "Unknown error": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Unbekannter Fehler"}},"es":{"stringUnit":{"state":"translated","value":"Error desconocido"}},"fr":{"stringUnit":{"state":"translated","value":"Erreur inconnue"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Erro desconhecido"}},"it":{"stringUnit":{"state":"translated","value":"Errore sconosciuto"}},"pl":{"stringUnit":{"state":"translated","value":"Nieznany błąd"}},"ru":{"stringUnit":{"state":"translated","value":"Неизвестная ошибка"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"未知错误"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"未知錯誤"}}}}, + "Capture coverage": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Abdeckung der Aufnahme"}},"es":{"stringUnit":{"state":"translated","value":"Cobertura de la captura"}},"fr":{"stringUnit":{"state":"translated","value":"Couverture de la capture"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Cobertura da captura"}},"it":{"stringUnit":{"state":"translated","value":"Copertura dell'acquisizione"}},"pl":{"stringUnit":{"state":"translated","value":"Pokrycie nagrania"}},"ru":{"stringUnit":{"state":"translated","value":"Покрытие записи"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"采集覆盖率"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"擷取涵蓋率"}}}}, + "Band: connected%@": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Band: verbunden%@"}},"es":{"stringUnit":{"state":"translated","value":"Pulsera: conectada%@"}},"fr":{"stringUnit":{"state":"translated","value":"Bracelet : connecté%@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Bracelete: ligada%@"}},"it":{"stringUnit":{"state":"translated","value":"Fascia: collegata%@"}},"pl":{"stringUnit":{"state":"translated","value":"Pasek: połączono%@"}},"ru":{"stringUnit":{"state":"translated","value":"Браслет: подключено%@"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"手环:已连接%@"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"手環:已連接%@"}}}}, + "Band: disconnected": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Band: getrennt"}},"es":{"stringUnit":{"state":"translated","value":"Pulsera: desconectada"}},"fr":{"stringUnit":{"state":"translated","value":"Bracelet : déconnecté"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Bracelete: desligada"}},"it":{"stringUnit":{"state":"translated","value":"Fascia: disconnessa"}},"pl":{"stringUnit":{"state":"translated","value":"Pasek: rozłączono"}},"ru":{"stringUnit":{"state":"translated","value":"Браслет: отключено"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"手环:已断开"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"手環:已中斷連接"}}}}, + "History sync: running (%lld chunks)": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Verlaufssynchronisierung läuft (%lld Blöcke)"}},"es":{"stringUnit":{"state":"translated","value":"Sincronizando historial (%lld bloques)"}},"fr":{"stringUnit":{"state":"translated","value":"Synchronisation de l’historique (%lld blocs)"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Sincronização do histórico (%lld blocos)"}},"it":{"stringUnit":{"state":"translated","value":"Sincronizzazione cronologia: in corso (%lld blocchi)"}},"pl":{"stringUnit":{"state":"translated","value":"Synchronizacja historii: w toku (%lld fragmentów)"}},"ru":{"stringUnit":{"state":"translated","value":"Синхронизация истории: выполняется (%lld фрагментов)"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"历史同步:进行中(%lld 个数据块)"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"歷史同步:進行中(%lld 個資料塊)"}}}}, + "History sync: idle": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Verlaufssynchronisierung inaktiv"}},"es":{"stringUnit":{"state":"translated","value":"Sincronización inactiva"}},"fr":{"stringUnit":{"state":"translated","value":"Synchronisation inactive"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Sincronização inativa"}},"it":{"stringUnit":{"state":"translated","value":"Sincronizzazione cronologia: inattiva"}},"pl":{"stringUnit":{"state":"translated","value":"Synchronizacja historii: bezczynna"}},"ru":{"stringUnit":{"state":"translated","value":"Синхронизация истории: простой"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"历史同步:空闲"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"歷史同步:閒置"}}}}, + "Realtime IMU: session active since %@": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Echtzeit-IMU: Session aktiv seit %@"}},"es":{"stringUnit":{"state":"translated","value":"IMU en tiempo real: sesión activa desde %@"}},"fr":{"stringUnit":{"state":"translated","value":"IMU en temps réel : session active depuis %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"IMU em tempo real: sessão ativa desde %@"}},"it":{"stringUnit":{"state":"translated","value":"IMU in tempo reale: sessione attiva da %@"}},"pl":{"stringUnit":{"state":"translated","value":"IMU w czasie rzeczywistym: sesja aktywna od %@"}},"ru":{"stringUnit":{"state":"translated","value":"IMU в реальном времени: сессия активна с %@"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"实时 IMU:会话自 %@ 起活动"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"即時 IMU:工作階段自 %@ 起進行中"}}}}, + "Historical export window": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Vergangenes Exportzeitfenster"}},"es":{"stringUnit":{"state":"translated","value":"Intervalo histórico de exportación"}},"fr":{"stringUnit":{"state":"translated","value":"Plage d’export historique"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Intervalo histórico de exportação"}},"it":{"stringUnit":{"state":"translated","value":"Finestra di esportazione storica"}},"pl":{"stringUnit":{"state":"translated","value":"Okno eksportu historycznego"}},"ru":{"stringUnit":{"state":"translated","value":"Окно исторического экспорта"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"历史导出时间窗"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"歷史匯出時間範圍"}}}}, + "Create a session from synchronized history without starting a live capture. 100 Hz coverage is included wherever it still exists in the rolling buffer.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Erstellt eine Session aus dem synchronisierten Verlauf. Noch gepufferte 100-Hz-Daten werden einbezogen."}},"es":{"stringUnit":{"state":"translated","value":"Crea una sesión desde el historial. Incluye los datos de 100 Hz aún disponibles."}},"fr":{"stringUnit":{"state":"translated","value":"Crée une session depuis l’historique. Inclut les données 100 Hz encore disponibles."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Cria uma sessão a partir do histórico. Inclui os dados de 100 Hz ainda disponíveis."}},"it":{"stringUnit":{"state":"translated","value":"Crea una sessione dalla cronologia sincronizzata senza avviare un'acquisizione dal vivo. La copertura a 100 Hz è inclusa dove è ancora presente nel buffer."}},"pl":{"stringUnit":{"state":"translated","value":"Utwórz sesję ze zsynchronizowanej historii bez uruchamiania nagrywania na żywo. Pokrycie 100 Hz jest dołączane tam, gdzie wciąż istnieje w buforze."}},"ru":{"stringUnit":{"state":"translated","value":"Создать сессию из синхронизированной истории без запуска живой записи. Покрытие 100 Гц включается там, где оно ещё есть в буфере."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"从已同步的历史创建会话,无需启动实时采集。滚动缓冲区中仍存在的 100 Hz 数据会一并包含。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"從已同步的歷史建立工作階段,無需啟動即時擷取。輪替緩衝區中仍存在的 100 Hz 資料會一併包含。"}}}}, + "Recorded sessions": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Aufgezeichnete Sessions"}},"es":{"stringUnit":{"state":"translated","value":"Sesiones grabadas"}},"fr":{"stringUnit":{"state":"translated","value":"Sessions enregistrées"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Sessões gravadas"}},"it":{"stringUnit":{"state":"translated","value":"Sessioni registrate"}},"pl":{"stringUnit":{"state":"translated","value":"Zarejestrowane sesje"}},"ru":{"stringUnit":{"state":"translated","value":"Записанные сессии"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"已记录会话"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"已記錄工作階段"}}}}, + "No sessions recorded yet.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Noch keine Sessions aufgezeichnet."}},"es":{"stringUnit":{"state":"translated","value":"Aún no hay sesiones grabadas."}},"fr":{"stringUnit":{"state":"translated","value":"Aucune session enregistrée."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Ainda não existem sessões gravadas."}},"it":{"stringUnit":{"state":"translated","value":"Nessuna sessione registrata."}},"pl":{"stringUnit":{"state":"translated","value":"Nie zarejestrowano jeszcze żadnych sesji."}},"ru":{"stringUnit":{"state":"translated","value":"Сессии ещё не записаны."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"尚未记录任何会话。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"尚未記錄任何工作階段。"}}}}, + "Export status: recording": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Exportstatus: Aufnahme läuft"}},"es":{"stringUnit":{"state":"translated","value":"Estado: grabando"}},"fr":{"stringUnit":{"state":"translated","value":"État : enregistrement"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Estado: a gravar"}},"it":{"stringUnit":{"state":"translated","value":"Stato esportazione: registrazione"}},"pl":{"stringUnit":{"state":"translated","value":"Stan eksportu: nagrywanie"}},"ru":{"stringUnit":{"state":"translated","value":"Состояние экспорта: запись"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"导出状态:正在记录"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"匯出狀態:記錄中"}}}}, + "IMU: %@": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"IMU: %@"}},"es":{"stringUnit":{"state":"translated","value":"IMU: %@"}},"fr":{"stringUnit":{"state":"translated","value":"IMU : %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"IMU: %@"}},"it":{"stringUnit":{"state":"translated","value":"IMU: %@"}},"pl":{"stringUnit":{"state":"translated","value":"IMU: %@"}},"ru":{"stringUnit":{"state":"translated","value":"IMU: %@"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"IMU:%@"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"IMU:%@"}}}}, + "Last exported %@ · export remains available": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Zuletzt exportiert: %@ · Export bleibt verfügbar"}},"es":{"stringUnit":{"state":"translated","value":"Última exportación: %@ · la exportación sigue disponible"}},"fr":{"stringUnit":{"state":"translated","value":"Dernier export : %@ · l’export reste disponible"}},"it":{"stringUnit":{"state":"translated","value":"Ultima esportazione: %@ · l’esportazione resta disponibile"}},"pl":{"stringUnit":{"state":"translated","value":"Ostatnio wyeksportowano: %@ · eksport pozostaje dostępny"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Última exportação: %@ · a exportação continua disponível"}},"ru":{"stringUnit":{"state":"translated","value":"Последний экспорт: %@ · экспорт остаётся доступен"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"上次导出:%@ · 仍可再次导出"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"上次匯出:%@ · 仍可再次匯出"}}}}, + "Export status: %lld raw batches": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Exportstatus: %lld Rohdatenblöcke"}},"es":{"stringUnit":{"state":"translated","value":"Estado: %lld bloques sin procesar"}},"fr":{"stringUnit":{"state":"translated","value":"État : %lld blocs bruts"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Estado: %lld blocos em bruto"}},"it":{"stringUnit":{"state":"translated","value":"Stato esportazione: %lld blocchi di dati grezzi"}},"pl":{"stringUnit":{"state":"translated","value":"Stan eksportu: %lld partii surowych danych"}},"ru":{"stringUnit":{"state":"translated","value":"Состояние экспорта: %lld пакетов сырых данных"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"导出状态:%lld 批原始数据"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"匯出狀態:%lld 批原始資料"}}}}, + "Legacy R22 feature-flag experiment. The strap accepts these writes, but NOOP has not observed them enabling a separate live stream. This is not required for normal WHOOP 5/MG support or for the Raw Data Collector. It writes persistent strap settings and may do nothing on your firmware.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Altes R22-Experiment. Für normalen 5/MG-Betrieb und den Rohdaten-Collector ist es nicht erforderlich."}},"es":{"stringUnit":{"state":"translated","value":"Experimento R22 antiguo. No es necesario para el uso normal ni para el recopilador de datos."}},"fr":{"stringUnit":{"state":"translated","value":"Ancienne expérience R22. Elle n’est pas requise pour l’usage normal ni pour le collecteur."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Experiência R22 antiga. Não é necessária para o uso normal nem para o coletor."}},"it":{"stringUnit":{"state":"translated","value":"Vecchio esperimento con i flag R22. La fascia accetta queste scritture, ma NOOP non ha osservato l'attivazione di un flusso dal vivo separato. Non è necessario per il normale supporto WHOOP 5/MG né per il Raccoglitore dati grezzi. Scrive impostazioni persistenti sulla fascia e potrebbe non avere effetto sul tuo firmware."}},"pl":{"stringUnit":{"state":"translated","value":"Dawny eksperyment z flagami R22. Pasek przyjmuje te zapisy, ale NOOP nie zaobserwował uruchomienia osobnego strumienia na żywo. Nie jest to wymagane do normalnej obsługi WHOOP 5/MG ani do Kolektora danych surowych. Zapisuje trwałe ustawienia paska i może nie działać na Twoim oprogramowaniu."}},"ru":{"stringUnit":{"state":"translated","value":"Старый эксперимент с флагами R22. Браслет принимает эти записи, но NOOP не наблюдал включения отдельного живого потока. Для обычной поддержки WHOOP 5/MG и Сборщика сырых данных это не требуется. Записывает постоянные настройки браслета и может ничего не дать на вашей прошивке."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"旧的 R22 功能标记实验。手环会接受这些写入,但 NOOP 未观察到由此启用独立的实时数据流。正常的 WHOOP 5/MG 支持和原始数据采集器都不需要它。它会写入持久的手环设置,在你的固件上可能毫无作用。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"舊的 R22 功能標記實驗。手環會接受這些寫入,但 NOOP 未觀察到因此啟用獨立的即時資料流。正常的 WHOOP 5/MG 支援與原始資料收集器都不需要它。它會寫入持久的手環設定,在你的韌體上可能毫無作用。"}}}}, + "NOOP estimates your steps from your WHOOP's stored motion, calibrated to your phone's step count. It's an estimate, not a hardware step counter; normal WHOOP 5/MG history sync supplies the motion data.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"NOOP schätzt Schritte aus gespeicherter Bewegung und kalibriert sie mit dem Telefon. Es ist kein Hardware-Schrittzähler."}},"es":{"stringUnit":{"state":"translated","value":"NOOP estima los pasos desde el movimiento y los calibra con el teléfono. No es un contador físico."}},"fr":{"stringUnit":{"state":"translated","value":"NOOP estime les pas depuis le mouvement et les calibre avec le téléphone. Ce n’est pas un compteur matériel."}},"pt-PT":{"stringUnit":{"state":"translated","value":"O NOOP estima os passos a partir do movimento e calibra-os com o telefone. Não é um contador físico."}},"it":{"stringUnit":{"state":"translated","value":"NOOP stima i tuoi passi dal movimento memorizzato dal WHOOP, calibrato sul contapassi del telefono. È una stima, non un contapassi hardware; i dati di movimento arrivano dalla normale sincronizzazione della cronologia WHOOP 5/MG."}},"pl":{"stringUnit":{"state":"translated","value":"NOOP szacuje kroki na podstawie ruchu zapisanego przez WHOOP, kalibrując je licznikiem kroków telefonu. To oszacowanie, a nie sprzętowy krokomierz; dane o ruchu pochodzą ze zwykłej synchronizacji historii WHOOP 5/MG."}},"ru":{"stringUnit":{"state":"translated","value":"NOOP оценивает шаги по сохранённому движению WHOOP, калибруя их по шагомеру телефона. Это оценка, а не аппаратный шагомер; данные о движении приходят из обычной синхронизации истории WHOOP 5/MG."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"NOOP 根据 WHOOP 存储的运动数据估算步数,并以手机步数进行校准。这是估算值,而非硬件计步器;运动数据来自正常的 WHOOP 5/MG 历史同步。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"NOOP 根據 WHOOP 儲存的運動資料估算步數,並以手機步數進行校準。這是估算值,而非硬體計步器;運動資料來自正常的 WHOOP 5/MG 歷史同步。"}}}}, + "5/MG RAW DATA COLLECTOR": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"5/MG-ROHDATEN-COLLECTOR"}},"es":{"stringUnit":{"state":"translated","value":"RECOPILADOR DE DATOS 5/MG"}},"fr":{"stringUnit":{"state":"translated","value":"COLLECTEUR DE DONNÉES 5/MG"}},"pt-PT":{"stringUnit":{"state":"translated","value":"RECOLHA DE DADOS 5/MG"}},"it":{"stringUnit":{"state":"translated","value":"RACCOGLITORE DATI GREZZI 5/MG"}},"pl":{"stringUnit":{"state":"translated","value":"KOLEKTOR DANYCH SUROWYCH 5/MG"}},"ru":{"stringUnit":{"state":"translated","value":"СБОРЩИК СЫРЫХ ДАННЫХ 5/MG"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"5/MG 原始数据采集器"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"5/MG 原始資料收集器"}}}}, + "Record, review, export, and delete bounded 100 Hz motion sessions. Normal sync is unchanged.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Begrenzte 100-Hz-Sessions aufnehmen, prüfen, exportieren und löschen. Normale Synchronisierung bleibt unverändert."}},"es":{"stringUnit":{"state":"translated","value":"Graba, revisa, exporta y elimina sesiones limitadas a 100 Hz. La sincronización normal no cambia."}},"fr":{"stringUnit":{"state":"translated","value":"Enregistrez, consultez, exportez et supprimez des sessions limitées à 100 Hz. La synchronisation normale ne change pas."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Grave, reveja, exporte e elimine sessões limitadas a 100 Hz. A sincronização normal não muda."}},"it":{"stringUnit":{"state":"translated","value":"Registra, esamina, esporta ed elimina sessioni di movimento a 100 Hz delimitate. La sincronizzazione normale non cambia."}},"pl":{"stringUnit":{"state":"translated","value":"Nagrywaj, przeglądaj, eksportuj i usuwaj ograniczone sesje ruchu 100 Hz. Zwykła synchronizacja pozostaje bez zmian."}},"ru":{"stringUnit":{"state":"translated","value":"Записывайте, просматривайте, экспортируйте и удаляйте ограниченные сессии движения 100 Гц. Обычная синхронизация не меняется."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"记录、查看、导出和删除有时长限制的 100 Hz 运动会话。正常同步不受影响。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"記錄、檢視、匯出並刪除有時長限制的 100 Hz 運動工作階段。正常同步不受影響。"}}}}, + "Open raw-data collector": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Rohdaten-Collector öffnen"}},"es":{"stringUnit":{"state":"translated","value":"Abrir recopilador de datos"}},"fr":{"stringUnit":{"state":"translated","value":"Ouvrir le collecteur de données"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Abrir recolha de dados"}},"it":{"stringUnit":{"state":"translated","value":"Apri il raccoglitore dati grezzi"}},"pl":{"stringUnit":{"state":"translated","value":"Otwórz kolektor danych surowych"}},"ru":{"stringUnit":{"state":"translated","value":"Открыть сборщик сырых данных"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"打开原始数据采集器"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"開啟原始資料收集器"}}}}, + "WHOOP 5/MG connected.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG verbunden."}},"es":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG conectado."}},"fr":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG connecté."}},"pt-PT":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG ligado."}},"it":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG collegato."}},"pl":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG połączony."}},"ru":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG подключён."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG 已连接。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG 已連接。"}}}}, + "Connect your WHOOP 5/MG to start a capture.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Verbinde dein WHOOP 5/MG, um eine Aufnahme zu starten."}},"es":{"stringUnit":{"state":"translated","value":"Conecta tu WHOOP 5/MG para iniciar una captura."}},"fr":{"stringUnit":{"state":"translated","value":"Connectez votre WHOOP 5/MG pour lancer une capture."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Ligue o WHOOP 5/MG para iniciar uma captura."}},"it":{"stringUnit":{"state":"translated","value":"Collega il tuo WHOOP 5/MG per avviare un'acquisizione."}},"pl":{"stringUnit":{"state":"translated","value":"Połącz WHOOP 5/MG, aby rozpocząć nagrywanie."}},"ru":{"stringUnit":{"state":"translated","value":"Подключите WHOOP 5/MG, чтобы начать запись."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"连接你的 WHOOP 5/MG 以开始采集。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"連接你的 WHOOP 5/MG 以開始擷取。"}}}}, + "5/MG PROTOCOL DIAGNOSTICS": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"5/MG-PROTOKOLLDIAGNOSE"}},"es":{"stringUnit":{"state":"translated","value":"DIAGNÓSTICO DEL PROTOCOLO 5/MG"}},"fr":{"stringUnit":{"state":"translated","value":"DIAGNOSTICS DU PROTOCOLE 5/MG"}},"pt-PT":{"stringUnit":{"state":"translated","value":"DIAGNÓSTICO DO PROTOCOLO 5/MG"}},"it":{"stringUnit":{"state":"translated","value":"DIAGNOSTICA PROTOCOLLO 5/MG"}},"pl":{"stringUnit":{"state":"translated","value":"DIAGNOSTYKA PROTOKOŁU 5/MG"}},"ru":{"stringUnit":{"state":"translated","value":"ДИАГНОСТИКА ПРОТОКОЛА 5/MG"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"5/MG 协议诊断"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"5/MG 協定診斷"}}}}, + "Developer tools for unmapped protocol features. None of these are required for normal WHOOP 5/MG recording, history sync, or the bounded Raw Data Collector.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Entwicklerwerkzeuge für nicht zugeordnete Protokollfunktionen. Für normale Aufnahme, Verlauf und Rohdaten-Collector nicht erforderlich."}},"es":{"stringUnit":{"state":"translated","value":"Herramientas para funciones de protocolo sin asignar. No son necesarias para el uso normal."}},"fr":{"stringUnit":{"state":"translated","value":"Outils pour les fonctions de protocole non identifiées. Ils ne sont pas requis pour l’usage normal."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Ferramentas para funções de protocolo não identificadas. Não são necessárias para o uso normal."}},"it":{"stringUnit":{"state":"translated","value":"Strumenti per sviluppatori per funzioni di protocollo non mappate. Nessuno è necessario per la normale registrazione WHOOP 5/MG, la sincronizzazione della cronologia o il Raccoglitore dati grezzi."}},"pl":{"stringUnit":{"state":"translated","value":"Narzędzia deweloperskie do niezmapowanych funkcji protokołu. Żadne z nich nie są wymagane do zwykłego nagrywania WHOOP 5/MG, synchronizacji historii ani Kolektora danych surowych."}},"ru":{"stringUnit":{"state":"translated","value":"Инструменты разработчика для неразобранных функций протокола. Ничего из этого не требуется для обычной записи WHOOP 5/MG, синхронизации истории или Сборщика сырых данных."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"用于未映射协议功能的开发者工具。正常的 WHOOP 5/MG 记录、历史同步和原始数据采集器都不需要这些。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"用於未對應協定功能的開發者工具。正常的 WHOOP 5/MG 記錄、歷史同步與原始資料收集器都不需要這些。"}}}}, + "Protocol probes": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Protokolltests"}},"es":{"stringUnit":{"state":"translated","value":"Sondas de protocolo"}},"fr":{"stringUnit":{"state":"translated","value":"Sondes de protocole"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Sondas de protocolo"}},"it":{"stringUnit":{"state":"translated","value":"Sonde di protocollo"}},"pl":{"stringUnit":{"state":"translated","value":"Sondy protokołu"}},"ru":{"stringUnit":{"state":"translated","value":"Пробы протокола"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"协议探测"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"協定探測"}}}}, + "Sends experimental protocol queries and records the replies in the strap log.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Sendet experimentelle Abfragen und protokolliert die Antworten."}},"es":{"stringUnit":{"state":"translated","value":"Envía consultas experimentales y registra las respuestas."}},"fr":{"stringUnit":{"state":"translated","value":"Envoie des requêtes expérimentales et consigne les réponses."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Envia consultas experimentais e regista as respostas."}},"it":{"stringUnit":{"state":"translated","value":"Invia query sperimentali e registra le risposte nel log della fascia."}},"pl":{"stringUnit":{"state":"translated","value":"Wysyła eksperymentalne zapytania protokołu i zapisuje odpowiedzi w dzienniku paska."}},"ru":{"stringUnit":{"state":"translated","value":"Отправляет экспериментальные запросы протокола и записывает ответы в журнал браслета."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"发送实验性协议查询,并将回复记录到手环日志中。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"傳送實驗性協定查詢,並將回覆記錄到手環日誌中。"}}}}, + "Broadcast heart rate from the strap": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Herzfrequenz vom Band übertragen"}},"es":{"stringUnit":{"state":"translated","value":"Transmitir frecuencia cardíaca desde la pulsera"}},"fr":{"stringUnit":{"state":"translated","value":"Diffuser la fréquence cardiaque du bracelet"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Transmitir frequência cardíaca da bracelete"}},"it":{"stringUnit":{"state":"translated","value":"Trasmetti la frequenza cardiaca dalla fascia"}},"pl":{"stringUnit":{"state":"translated","value":"Nadawaj tętno z paska"}},"ru":{"stringUnit":{"state":"translated","value":"Транслировать пульс с браслета"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"从手环广播心率"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"從手環廣播心率"}}}}, + "Writes the reversible 5/MG advertising flag for Garmin, Zwift, and compatible gym equipment.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Setzt das umkehrbare 5/MG-Werbe-Flag für kompatible Geräte."}},"es":{"stringUnit":{"state":"translated","value":"Activa el indicador reversible 5/MG para equipos compatibles."}},"fr":{"stringUnit":{"state":"translated","value":"Active l’indicateur réversible 5/MG pour les appareils compatibles."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Ativa o indicador reversível 5/MG para equipamento compatível."}},"it":{"stringUnit":{"state":"translated","value":"Scrive il flag di advertising 5/MG reversibile per Garmin, Zwift e attrezzature da palestra compatibili."}},"pl":{"stringUnit":{"state":"translated","value":"Zapisuje odwracalną flagę rozgłaszania 5/MG dla Garmina, Zwifta i zgodnego sprzętu na siłowni."}},"ru":{"stringUnit":{"state":"translated","value":"Записывает обратимый флаг вещания 5/MG для Garmin, Zwift и совместимых тренажёров."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"写入可撤销的 5/MG 广播标记,供 Garmin、Zwift 及兼容健身器材使用。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"寫入可撤銷的 5/MG 廣播標記,供 Garmin、Zwift 及相容健身器材使用。"}}}}, + "Legacy R22 feature-flag experiment": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Altes R22-Feature-Flag-Experiment"}},"es":{"stringUnit":{"state":"translated","value":"Experimento antiguo de indicadores R22"}},"fr":{"stringUnit":{"state":"translated","value":"Ancienne expérience d’indicateurs R22"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Experiência antiga de indicadores R22"}},"it":{"stringUnit":{"state":"translated","value":"Vecchio esperimento con i flag R22"}},"pl":{"stringUnit":{"state":"translated","value":"Dawny eksperyment z flagami R22"}},"ru":{"stringUnit":{"state":"translated","value":"Старый эксперимент с флагами R22"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"旧的 R22 功能标记实验"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"舊的 R22 功能標記實驗"}}}}, + "The strap accepts these writes, but NOOP has not observed them enabling a separate live stream. This is not the Raw Data Collector.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Das Band akzeptiert die Schreibvorgänge, ein eigener Live-Stream wurde aber nicht beobachtet. Dies ist nicht der Rohdaten-Collector."}},"es":{"stringUnit":{"state":"translated","value":"La pulsera acepta estas escrituras, pero no se ha observado otro flujo. Esto no es el recopilador."}},"fr":{"stringUnit":{"state":"translated","value":"Le bracelet accepte ces écritures, mais aucun flux distinct n’a été observé. Ce n’est pas le collecteur."}},"pt-PT":{"stringUnit":{"state":"translated","value":"A bracelete aceita estas escritas, mas não foi observado outro fluxo. Isto não é a recolha de dados."}},"it":{"stringUnit":{"state":"translated","value":"La fascia accetta queste scritture, ma NOOP non ha osservato l'attivazione di un flusso dal vivo separato. Questo non è il Raccoglitore dati grezzi."}},"pl":{"stringUnit":{"state":"translated","value":"Pasek przyjmuje te zapisy, ale NOOP nie zaobserwował uruchomienia osobnego strumienia na żywo. To nie jest Kolektor danych surowych."}},"ru":{"stringUnit":{"state":"translated","value":"Браслет принимает эти записи, но NOOP не наблюдал включения отдельного живого потока. Это не Сборщик сырых данных."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"手环会接受这些写入,但 NOOP 未观察到由此启用独立的实时数据流。这不是原始数据采集器。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"手環會接受這些寫入,但 NOOP 未觀察到因此啟用獨立的即時資料流。這不是原始資料收集器。"}}}}, + "WHOOP MG ECG raw-data gate": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"WHOOP-MG-Schalter für ECG-Rohdaten"}},"es":{"stringUnit":{"state":"translated","value":"Control de datos ECG de WHOOP MG"}},"fr":{"stringUnit":{"state":"translated","value":"Contrôle des données ECG WHOOP MG"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Controlo de dados ECG WHOOP MG"}},"it":{"stringUnit":{"state":"translated","value":"Interruttore dati grezzi ECG per WHOOP MG"}},"pl":{"stringUnit":{"state":"translated","value":"Przełącznik surowych danych EKG WHOOP MG"}},"ru":{"stringUnit":{"state":"translated","value":"Переключатель сырых данных ЭКГ WHOOP MG"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"WHOOP MG 心电原始数据开关"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"WHOOP MG 心電原始資料開關"}}}}, + "MG-only protocol instrumentation, not a medical ECG feature.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Nur MG-Protokolltests, keine medizinische ECG-Funktion."}},"es":{"stringUnit":{"state":"translated","value":"Instrumentación solo para MG; no es una función ECG médica."}},"fr":{"stringUnit":{"state":"translated","value":"Instrumentation MG uniquement, pas une fonction ECG médicale."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Instrumentação apenas para MG; não é uma função ECG médica."}},"it":{"stringUnit":{"state":"translated","value":"Strumentazione di protocollo solo per MG, non una funzione ECG medica."}},"pl":{"stringUnit":{"state":"translated","value":"Instrumentacja protokołu tylko dla MG, nie jest to medyczna funkcja EKG."}},"ru":{"stringUnit":{"state":"translated","value":"Протокольная инструментовка только для MG, а не медицинская функция ЭКГ."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"仅限 MG 的协议检测,并非医疗心电功能。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"僅限 MG 的協定檢測,並非醫療心電功能。"}}}}, + "Passive history/protocol trace": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Passive Verlaufs-/Protokollaufzeichnung"}},"es":{"stringUnit":{"state":"translated","value":"Registro pasivo de historial/protocolo"}},"fr":{"stringUnit":{"state":"translated","value":"Trace passive d’historique/protocole"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Registo passivo de histórico/protocolo"}},"it":{"stringUnit":{"state":"translated","value":"Traccia passiva di cronologia/protocollo"}},"pl":{"stringUnit":{"state":"translated","value":"Pasywny zapis historii/protokołu"}},"ru":{"stringUnit":{"state":"translated","value":"Пассивная трассировка истории/протокола"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"被动历史/协议追踪"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"被動歷史/協定追蹤"}}}}, + "Records frames that already arrive. It does not start sensors and can create large files. Use the export section below to save the trace with its strap log.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Zeichnet bereits eintreffende Frames auf, startet keine Sensoren und kann große Dateien erzeugen. Der Export unten fügt das Bandprotokoll hinzu."}},"es":{"stringUnit":{"state":"translated","value":"Registra las tramas que ya llegan. No inicia sensores y puede crear archivos grandes. El export inferior incluye el registro."}},"fr":{"stringUnit":{"state":"translated","value":"Enregistre les trames déjà reçues. Ne démarre aucun capteur et peut créer de gros fichiers. L’export ci-dessous joint le journal."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Regista os pacotes que já chegam. Não inicia sensores e pode criar ficheiros grandes. A exportação abaixo inclui o registo."}},"it":{"stringUnit":{"state":"translated","value":"Registra i frame che arrivano già. Non avvia i sensori e può creare file di grandi dimensioni. Usa la sezione di esportazione qui sotto per salvare la traccia con il log della fascia."}},"pl":{"stringUnit":{"state":"translated","value":"Zapisuje ramki, które i tak przychodzą. Nie uruchamia czujników i może tworzyć duże pliki. Użyj sekcji eksportu poniżej, aby zapisać zapis wraz z dziennikiem paska."}},"ru":{"stringUnit":{"state":"translated","value":"Записывает кадры, которые и так приходят. Не запускает датчики и может создавать большие файлы. Используйте раздел экспорта ниже, чтобы сохранить трассировку вместе с журналом браслета."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"记录本就到达的数据帧。它不会启动传感器,并可能生成很大的文件。请使用下方的导出部分,将追踪记录连同手环日志一起保存。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"記錄本就到達的資料框。它不會啟動感測器,並可能產生很大的檔案。請使用下方的匯出區塊,將追蹤記錄連同手環日誌一起儲存。"}}}}, + "Couldn't delete session": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Session konnte nicht gelöscht werden"}},"es":{"stringUnit":{"state":"translated","value":"No se pudo eliminar la sesión"}},"fr":{"stringUnit":{"state":"translated","value":"Impossible de supprimer la session"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Não foi possível eliminar a sessão"}},"it":{"stringUnit":{"state":"translated","value":"Impossibile eliminare la sessione"}},"pl":{"stringUnit":{"state":"translated","value":"Nie można usunąć sesji"}},"ru":{"stringUnit":{"state":"translated","value":"Не удалось удалить сессию"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"无法删除会话"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"無法刪除工作階段"}}}}, + "5/MG Raw Data Collector": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"5/MG-Rohdaten-Collector"}},"es":{"stringUnit":{"state":"translated","value":"Recopilador de datos sin procesar 5/MG"}},"fr":{"stringUnit":{"state":"translated","value":"Collecteur de données brutes 5/MG"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Recolha de dados em bruto 5/MG"}},"it":{"stringUnit":{"state":"translated","value":"Raccoglitore dati grezzi 5/MG"}},"pl":{"stringUnit":{"state":"translated","value":"Kolektor danych surowych 5/MG"}},"ru":{"stringUnit":{"state":"translated","value":"Сборщик сырых данных 5/MG"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"5/MG 原始数据采集器"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"5/MG 原始資料收集器"}}}}, + "Record a bounded 100 Hz motion session and export its complete timeline.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Eine begrenzte 100-Hz-Bewegungssession aufnehmen und vollständig exportieren."}},"es":{"stringUnit":{"state":"translated","value":"Graba una sesión de movimiento limitada a 100 Hz y exporta su cronología completa."}},"fr":{"stringUnit":{"state":"translated","value":"Enregistrez une session de mouvement 100 Hz limitée et exportez sa chronologie complète."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Grave uma sessão de movimento limitada a 100 Hz e exporte a cronologia completa."}},"it":{"stringUnit":{"state":"translated","value":"Registra una sessione di movimento a 100 Hz delimitata ed esportane la cronologia completa."}},"pl":{"stringUnit":{"state":"translated","value":"Nagraj ograniczoną sesję ruchu 100 Hz i wyeksportuj jej pełną oś czasu."}},"ru":{"stringUnit":{"state":"translated","value":"Запишите ограниченную сессию движения 100 Гц и экспортируйте её полную временную шкалу."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"记录一段有时长限制的 100 Hz 运动会话,并导出其完整时间轴。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"記錄一段有時長限制的 100 Hz 運動工作階段,並匯出其完整時間軸。"}}}}, + "Start raw-data session": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Rohdaten-Session starten"}},"es":{"stringUnit":{"state":"translated","value":"Iniciar sesión de datos sin procesar"}},"fr":{"stringUnit":{"state":"translated","value":"Démarrer la session de données brutes"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Iniciar sessão de dados em bruto"}},"it":{"stringUnit":{"state":"translated","value":"Avvia sessione dati grezzi"}},"pl":{"stringUnit":{"state":"translated","value":"Rozpocznij sesję danych surowych"}},"ru":{"stringUnit":{"state":"translated","value":"Начать сессию сырых данных"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"开始原始数据会话"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"開始原始資料工作階段"}}}}, + "Add historical session": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Historische Session hinzufügen"}},"es":{"stringUnit":{"state":"translated","value":"Añadir sesión histórica"}},"fr":{"stringUnit":{"state":"translated","value":"Ajouter une session historique"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Adicionar sessão histórica"}},"it":{"stringUnit":{"state":"translated","value":"Aggiungi sessione storica"}},"pl":{"stringUnit":{"state":"translated","value":"Dodaj sesję historyczną"}},"ru":{"stringUnit":{"state":"translated","value":"Добавить историческую сессию"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"添加历史会话"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"加入歷史工作階段"}}}}, + "Delete all sessions": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Alle Sessions löschen"}},"es":{"stringUnit":{"state":"translated","value":"Eliminar todas las sesiones"}},"fr":{"stringUnit":{"state":"translated","value":"Supprimer toutes les sessions"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Eliminar todas as sessões"}},"it":{"stringUnit":{"state":"translated","value":"Elimina tutte le sessioni"}},"pl":{"stringUnit":{"state":"translated","value":"Usuń wszystkie sesje"}},"ru":{"stringUnit":{"state":"translated","value":"Удалить все сессии"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"删除所有会话"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"刪除所有工作階段"}}}}, + "Session comment": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Session-Kommentar"}},"es":{"stringUnit":{"state":"translated","value":"Comentario de la sesión"}},"fr":{"stringUnit":{"state":"translated","value":"Commentaire de session"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Comentário da sessão"}},"it":{"stringUnit":{"state":"translated","value":"Commento della sessione"}},"pl":{"stringUnit":{"state":"translated","value":"Komentarz do sesji"}},"ru":{"stringUnit":{"state":"translated","value":"Комментарий к сессии"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"会话备注"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"工作階段備註"}}}}, + "Building export…": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Export wird erstellt …"}},"es":{"stringUnit":{"state":"translated","value":"Creando exportación…"}},"fr":{"stringUnit":{"state":"translated","value":"Création de l’export…"}},"pt-PT":{"stringUnit":{"state":"translated","value":"A criar exportação…"}},"it":{"stringUnit":{"state":"translated","value":"Creazione esportazione…"}},"pl":{"stringUnit":{"state":"translated","value":"Tworzenie eksportu…"}},"ru":{"stringUnit":{"state":"translated","value":"Формирование экспорта…"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"正在生成导出…"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"正在建立匯出…"}}}}, + "Export session": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Session exportieren"}},"es":{"stringUnit":{"state":"translated","value":"Exportar sesión"}},"fr":{"stringUnit":{"state":"translated","value":"Exporter la session"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Exportar sessão"}},"it":{"stringUnit":{"state":"translated","value":"Esporta sessione"}},"pl":{"stringUnit":{"state":"translated","value":"Eksportuj sesję"}},"ru":{"stringUnit":{"state":"translated","value":"Экспортировать сессию"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"导出会话"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"匯出工作階段"}}}}, + "Delete session": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Session löschen"}},"es":{"stringUnit":{"state":"translated","value":"Eliminar sesión"}},"fr":{"stringUnit":{"state":"translated","value":"Supprimer la session"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Eliminar sessão"}},"it":{"stringUnit":{"state":"translated","value":"Elimina sessione"}},"pl":{"stringUnit":{"state":"translated","value":"Usuń sesję"}},"ru":{"stringUnit":{"state":"translated","value":"Удалить сессию"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"删除会话"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"刪除工作階段"}}}}, + "The export file could not be created or shared.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Die Exportdatei konnte nicht erstellt oder geteilt werden."}},"es":{"stringUnit":{"state":"translated","value":"No se pudo crear ni compartir el archivo de exportación."}},"fr":{"stringUnit":{"state":"translated","value":"Le fichier d’export n’a pas pu être créé ou partagé."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Não foi possível criar ou partilhar o ficheiro de exportação."}},"it":{"stringUnit":{"state":"translated","value":"Impossibile creare o condividere il file di esportazione."}},"pl":{"stringUnit":{"state":"translated","value":"Nie udało się utworzyć ani udostępnić pliku eksportu."}},"ru":{"stringUnit":{"state":"translated","value":"Не удалось создать файл экспорта или поделиться им."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"无法创建或共享导出文件。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"無法建立或分享匯出檔案。"}}}}, + "To": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Bis"}},"es":{"stringUnit":{"state":"translated","value":"Hasta"}},"fr":{"stringUnit":{"state":"translated","value":"Au"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Até"}},"it":{"stringUnit":{"state":"translated","value":"A"}},"pl":{"stringUnit":{"state":"translated","value":"Do"}},"ru":{"stringUnit":{"state":"translated","value":"До"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"至"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"至"}}}}, + "0": {"shouldTranslate":false}, + "Connect your WHOOP 5/MG to start a raw-data session.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Verbinde dein WHOOP 5/MG, um eine Rohdaten-Session zu starten."}},"es":{"stringUnit":{"state":"translated","value":"Conecta tu WHOOP 5/MG para iniciar una sesión de datos sin procesar."}},"fr":{"stringUnit":{"state":"translated","value":"Connectez votre WHOOP 5/MG pour démarrer une session de données brutes."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Ligue o WHOOP 5/MG para iniciar uma sessão de dados em bruto."}},"it":{"stringUnit":{"state":"translated","value":"Collega il tuo WHOOP 5/MG per avviare una sessione dati grezzi."}},"pl":{"stringUnit":{"state":"translated","value":"Połącz WHOOP 5/MG, aby rozpocząć sesję danych surowych."}},"ru":{"stringUnit":{"state":"translated","value":"Подключите WHOOP 5/MG, чтобы начать сессию сырых данных."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"连接你的 WHOOP 5/MG 以开始原始数据会话。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"連接你的 WHOOP 5/MG 以開始原始資料工作階段。"}}}}, + "Add marker": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Marker hinzufügen"}},"en":{"stringUnit":{"state":"translated","value":"Add marker"}},"es":{"stringUnit":{"state":"translated","value":"Añadir marcador"}},"fr":{"stringUnit":{"state":"translated","value":"Ajouter un marqueur"}},"it":{"stringUnit":{"state":"translated","value":"Aggiungi indicatore"}},"pl":{"stringUnit":{"state":"translated","value":"Dodaj znacznik"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Adicionar marcador"}},"ru":{"stringUnit":{"state":"translated","value":"Добавить маркер"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"添加标记"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"加入標記"}}}}, + "Edit marker": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Marker bearbeiten"}},"en":{"stringUnit":{"state":"translated","value":"Edit marker"}},"es":{"stringUnit":{"state":"translated","value":"Editar marcador"}},"fr":{"stringUnit":{"state":"translated","value":"Modifier le marqueur"}},"it":{"stringUnit":{"state":"translated","value":"Modifica indicatore"}},"pl":{"stringUnit":{"state":"translated","value":"Edytuj znacznik"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Editar marcador"}},"ru":{"stringUnit":{"state":"translated","value":"Изменить маркер"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"编辑标记"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"編輯標記"}}}}, + "Delete marker": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Marker löschen"}},"en":{"stringUnit":{"state":"translated","value":"Delete marker"}},"es":{"stringUnit":{"state":"translated","value":"Eliminar marcador"}},"fr":{"stringUnit":{"state":"translated","value":"Supprimer le marqueur"}},"it":{"stringUnit":{"state":"translated","value":"Elimina indicatore"}},"pl":{"stringUnit":{"state":"translated","value":"Usuń znacznik"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Eliminar marcador"}},"ru":{"stringUnit":{"state":"translated","value":"Удалить маркер"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"删除标记"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"刪除標記"}}}}, + "Marker type": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Markertyp"}},"en":{"stringUnit":{"state":"translated","value":"Marker type"}},"es":{"stringUnit":{"state":"translated","value":"Tipo de marcador"}},"fr":{"stringUnit":{"state":"translated","value":"Type de marqueur"}},"it":{"stringUnit":{"state":"translated","value":"Tipo di indicatore"}},"pl":{"stringUnit":{"state":"translated","value":"Typ znacznika"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Tipo de marcador"}},"ru":{"stringUnit":{"state":"translated","value":"Тип маркера"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"标记类型"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"標記類型"}}}}, + "Marker note": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Markernotiz"}},"en":{"stringUnit":{"state":"translated","value":"Marker note"}},"es":{"stringUnit":{"state":"translated","value":"Nota del marcador"}},"fr":{"stringUnit":{"state":"translated","value":"Note du marqueur"}},"it":{"stringUnit":{"state":"translated","value":"Nota indicatore"}},"pl":{"stringUnit":{"state":"translated","value":"Notatka znacznika"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Nota do marcador"}},"ru":{"stringUnit":{"state":"translated","value":"Заметка маркера"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"标记备注"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"標記備註"}}}}, + "Current time: %@": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Aktuelle Zeit: %@"}},"en":{"stringUnit":{"state":"translated","value":"Current time: %@"}},"es":{"stringUnit":{"state":"translated","value":"Hora actual: %@"}},"fr":{"stringUnit":{"state":"translated","value":"Heure actuelle : %@"}},"it":{"stringUnit":{"state":"translated","value":"Ora attuale: %@"}},"pl":{"stringUnit":{"state":"translated","value":"Bieżący czas: %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Hora atual: %@"}},"ru":{"stringUnit":{"state":"translated","value":"Текущее время: %@"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"当前时间:%@"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"目前時間:%@"}}}}, + "Marker: %@": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Marker: %@"}},"en":{"stringUnit":{"state":"translated","value":"Marker: %@"}},"es":{"stringUnit":{"state":"translated","value":"Marcador: %@"}},"fr":{"stringUnit":{"state":"translated","value":"Marqueur : %@"}},"it":{"stringUnit":{"state":"translated","value":"Indicatore: %@"}},"pl":{"stringUnit":{"state":"translated","value":"Znacznik: %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Marcador: %@"}},"ru":{"stringUnit":{"state":"translated","value":"Маркер: %@"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"标记:%@"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"標記:%@"}}}}, + "Moment": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Moment"}},"en":{"stringUnit":{"state":"translated","value":"Moment"}},"es":{"stringUnit":{"state":"translated","value":"Momento"}},"fr":{"stringUnit":{"state":"translated","value":"Moment"}},"it":{"stringUnit":{"state":"translated","value":"Momento"}},"pl":{"stringUnit":{"state":"translated","value":"Moment"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Momento"}},"ru":{"stringUnit":{"state":"translated","value":"Момент"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"时刻"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"時刻"}}}}, + "Issue": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Problem"}},"en":{"stringUnit":{"state":"translated","value":"Issue"}},"es":{"stringUnit":{"state":"translated","value":"Problema"}},"fr":{"stringUnit":{"state":"translated","value":"Problème"}},"it":{"stringUnit":{"state":"translated","value":"Problema"}},"pl":{"stringUnit":{"state":"translated","value":"Problem"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Problema"}},"ru":{"stringUnit":{"state":"translated","value":"Проблема"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"问题"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"問題"}}}}, + "−10 s": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"−10 s"}},"en":{"stringUnit":{"state":"translated","value":"−10 s"}},"es":{"stringUnit":{"state":"translated","value":"−10 s"}},"fr":{"stringUnit":{"state":"translated","value":"−10 s"}},"it":{"stringUnit":{"state":"translated","value":"−10 s"}},"pl":{"stringUnit":{"state":"translated","value":"−10 s"}},"pt-PT":{"stringUnit":{"state":"translated","value":"−10 s"}},"ru":{"stringUnit":{"state":"translated","value":"−10 с"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"−10 秒"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"−10 秒"}}}}, + "+10 s": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"+10 s"}},"en":{"stringUnit":{"state":"translated","value":"+10 s"}},"es":{"stringUnit":{"state":"translated","value":"+10 s"}},"fr":{"stringUnit":{"state":"translated","value":"+10 s"}},"it":{"stringUnit":{"state":"translated","value":"+10 s"}},"pl":{"stringUnit":{"state":"translated","value":"+10 s"}},"pt-PT":{"stringUnit":{"state":"translated","value":"+10 s"}},"ru":{"stringUnit":{"state":"translated","value":"+10 с"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"+10 秒"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"+10 秒"}}}}, + " · %@": {"localizations":{"de":{"stringUnit":{"state":"translated","value":" · %@"}},"en":{"stringUnit":{"state":"translated","value":" · %@"}},"es":{"stringUnit":{"state":"translated","value":" · %@"}},"fr":{"stringUnit":{"state":"translated","value":" · %@"}},"it":{"stringUnit":{"state":"translated","value":" · %@"}},"pl":{"stringUnit":{"state":"translated","value":" · %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":" · %@"}},"ru":{"stringUnit":{"state":"translated","value":" · %@"}},"zh-Hans":{"stringUnit":{"state":"translated","value":" · %@"}},"zh-Hant":{"stringUnit":{"state":"translated","value":" · %@"}}}}, + "Check what the strap has stored": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Prüfen, was das Band gespeichert hat" } }, "en": { "stringUnit": { "state": "translated", "value": "Check what the strap has stored" } }, "es": { "stringUnit": { "state": "translated", "value": "Comprobar qué tiene guardado la correa" } }, "fr": { "stringUnit": { "state": "translated", "value": "Vérifier ce que le bracelet a enregistré" } }, "it": { "stringUnit": { "state": "translated", "value": "Controlla cosa ha memorizzato il braccialetto" } }, "pl": { "stringUnit": { "state": "translated", "value": "Sprawdź, co zapisała opaska" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Verificar o que a pulseira tem guardado" } }, "ru": { "stringUnit": { "state": "translated", "value": "Проверить, что сохранено на ремешке" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "查看手环已存储的闹钟" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "查看手環已儲存的鬧鐘" } } + } }, + "The answer from the strap appears in your strap log and debug export, with the raw bytes it replied with.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Die Antwort des Bands erscheint im Band-Log und im Diagnose-Export, samt der empfangenen Rohbytes." } }, "en": { "stringUnit": { "state": "translated", "value": "The answer from the strap appears in your strap log and debug export, with the raw bytes it replied with." } }, "es": { "stringUnit": { "state": "translated", "value": "La respuesta de la correa aparece en el registro y en la exportación de diagnóstico, con los bytes recibidos." } }, "fr": { "stringUnit": { "state": "translated", "value": "La réponse du bracelet apparaît dans le journal et l’export de diagnostic, avec les octets bruts reçus." } }, "it": { "stringUnit": { "state": "translated", "value": "La risposta del braccialetto compare nel log e nell’esportazione diagnostica, con i byte grezzi ricevuti." } }, "pl": { "stringUnit": { "state": "translated", "value": "Odpowiedź opaski pojawi się w logu i eksporcie diagnostycznym, wraz z otrzymanymi surowymi bajtami." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A resposta da pulseira aparece no registo e na exportação de diagnóstico, com os bytes recebidos." } }, "ru": { "stringUnit": { "state": "translated", "value": "Ответ ремешка появится в журнале ремешка и диагностическом экспорте вместе с полученными байтами." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "手环的回应会出现在手环日志和诊断导出中,并附带收到的原始字节。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "手環的回應會出現在手環日誌和診斷匯出中,並附上收到的原始位元組。" } } + } }, + "Last night against your clock": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Letzte Nacht im Vergleich zu deiner inneren Uhr"}},"es":{"stringUnit":{"state":"translated","value":"Anoche frente a tu reloj interno"}},"fr":{"stringUnit":{"state":"translated","value":"La nuit dernière face à votre horloge"}},"it":{"stringUnit":{"state":"translated","value":"La scorsa notte rispetto al tuo orologio"}},"pl":{"stringUnit":{"state":"translated","value":"Ostatnia noc a Twój zegar biologiczny"}},"pt-PT":{"stringUnit":{"state":"translated","value":"A noite passada face ao seu relógio"}},"ru":{"stringUnit":{"state":"translated","value":"Прошлая ночь и ваши биологические часы"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"昨夜与你的生物钟对比"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"昨夜與你的生理時鐘對比"}}}}, + "In sync with your body clock": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Im Einklang mit deiner inneren Uhr"}},"es":{"stringUnit":{"state":"translated","value":"En sintonía con tu reloj interno"}},"fr":{"stringUnit":{"state":"translated","value":"En phase avec votre horloge biologique"}},"it":{"stringUnit":{"state":"translated","value":"In sintonia con il tuo orologio biologico"}},"pl":{"stringUnit":{"state":"translated","value":"Zgodnie z zegarem biologicznym"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Em sintonia com o seu relógio biológico"}},"ru":{"stringUnit":{"state":"translated","value":"В согласии с биологическими часами"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"与生物钟同步"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"與生理時鐘同步"}}}}, + "%@ later than your body clock": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"%@ später als deine innere Uhr"}},"es":{"stringUnit":{"state":"translated","value":"%@ más tarde que tu reloj interno"}},"fr":{"stringUnit":{"state":"translated","value":"%@ plus tard que votre horloge biologique"}},"it":{"stringUnit":{"state":"translated","value":"%@ più tardi del tuo orologio biologico"}},"pl":{"stringUnit":{"state":"translated","value":"%@ później niż Twój zegar biologiczny"}},"pt-PT":{"stringUnit":{"state":"translated","value":"%@ mais tarde do que o seu relógio biológico"}},"ru":{"stringUnit":{"state":"translated","value":"На %@ позже биологических часов"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"比生物钟晚 %@"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"比生理時鐘晚 %@"}}}}, + "%@ earlier than your body clock": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"%@ früher als deine innere Uhr"}},"es":{"stringUnit":{"state":"translated","value":"%@ más temprano que tu reloj interno"}},"fr":{"stringUnit":{"state":"translated","value":"%@ plus tôt que votre horloge biologique"}},"it":{"stringUnit":{"state":"translated","value":"%@ più presto del tuo orologio biologico"}},"pl":{"stringUnit":{"state":"translated","value":"%@ wcześniej niż Twój zegar biologiczny"}},"pt-PT":{"stringUnit":{"state":"translated","value":"%@ mais cedo do que o seu relógio biológico"}},"ru":{"stringUnit":{"state":"translated","value":"На %@ раньше биологических часов"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"比生物钟早 %@"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"比生理時鐘早 %@"}}}}, + "Morning type": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Morgentyp"}},"es":{"stringUnit":{"state":"translated","value":"Tipo matutino"}},"fr":{"stringUnit":{"state":"translated","value":"Type matinal"}},"it":{"stringUnit":{"state":"translated","value":"Tipo mattutino"}},"pl":{"stringUnit":{"state":"translated","value":"Typ poranny"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Tipo matutino"}},"ru":{"stringUnit":{"state":"translated","value":"Утренний тип"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"晨型人"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"晨型人"}}}}, + "Intermediate type": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Mischtyp"}},"es":{"stringUnit":{"state":"translated","value":"Tipo intermedio"}},"fr":{"stringUnit":{"state":"translated","value":"Type intermédiaire"}},"it":{"stringUnit":{"state":"translated","value":"Tipo intermedio"}},"pl":{"stringUnit":{"state":"translated","value":"Typ pośredni"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Tipo intermédio"}},"ru":{"stringUnit":{"state":"translated","value":"Промежуточный тип"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"中间型"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"中間型"}}}}, + "Evening type": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Abendtyp"}},"es":{"stringUnit":{"state":"translated","value":"Tipo vespertino"}},"fr":{"stringUnit":{"state":"translated","value":"Type vespéral"}},"it":{"stringUnit":{"state":"translated","value":"Tipo serale"}},"pl":{"stringUnit":{"state":"translated","value":"Typ wieczorny"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Tipo vespertino"}},"ru":{"stringUnit":{"state":"translated","value":"Вечерний тип"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"夜型人"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"夜型人"}}}}, + "Body clock dial": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Zifferblatt der inneren Uhr"}},"es":{"stringUnit":{"state":"translated","value":"Esfera del reloj interno"}},"fr":{"stringUnit":{"state":"translated","value":"Cadran de l'horloge biologique"}},"it":{"stringUnit":{"state":"translated","value":"Quadrante dell'orologio biologico"}},"pl":{"stringUnit":{"state":"translated","value":"Tarcza zegara biologicznego"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Mostrador do relógio biológico"}},"ru":{"stringUnit":{"state":"translated","value":"Циферблат биологических часов"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"生物钟表盘"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"生理時鐘錶盤"}}}}, + "Your clock": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Deine innere Uhr"}},"es":{"stringUnit":{"state":"translated","value":"Tu reloj interno"}},"fr":{"stringUnit":{"state":"translated","value":"Votre horloge"}},"it":{"stringUnit":{"state":"translated","value":"Il tuo orologio"}},"pl":{"stringUnit":{"state":"translated","value":"Twój zegar"}},"pt-PT":{"stringUnit":{"state":"translated","value":"O seu relógio"}},"ru":{"stringUnit":{"state":"translated","value":"Ваши часы"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"你的生物钟"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"你的生理時鐘"}}}}, + "iCloud Drive": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"es":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"fr":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"it":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"pl":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"pt-PT":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"ru":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"iCloud 云盘"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"iCloud 雲碟"}}}}, + "iCloud Drive › %@": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"es":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"fr":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"it":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"pl":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"ru":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"iCloud 云盘 › %@"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"iCloud 雲碟 › %@"}}}}, + "NOOP (in Files)": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"NOOP (in Dateien)"}},"es":{"stringUnit":{"state":"translated","value":"NOOP (en Archivos)"}},"fr":{"stringUnit":{"state":"translated","value":"NOOP (dans Fichiers)"}},"it":{"stringUnit":{"state":"translated","value":"NOOP (in File)"}},"pl":{"stringUnit":{"state":"translated","value":"NOOP (w Plikach)"}},"pt-PT":{"stringUnit":{"state":"translated","value":"NOOP (em Ficheiros)"}},"ru":{"stringUnit":{"state":"translated","value":"NOOP (в Файлах)"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"NOOP(在“文件”中)"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"NOOP(在「檔案」中)"}}}}, + "Live heart rate is streaming, but your strap is not fully paired. The encrypted pairing is what carries motion, skin temperature, SpO₂ and respiratory rate — without it, sleep is staged from heart rate alone. Buzz, alarms and history sync need it too.": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Die Live-Herzfrequenz wird übertragen, aber dein Strap ist nicht vollständig gekoppelt. Die verschlüsselte Kopplung liefert Bewegung, Hauttemperatur, SpO₂ und Atemfrequenz — ohne sie wird der Schlaf allein aus der Herzfrequenz abgeleitet. Auch Buzz, Wecker und Verlaufssynchronisierung brauchen sie."}},"es":{"stringUnit":{"state":"translated","value":"La frecuencia cardíaca en vivo se está transmitiendo, pero tu correa no está totalmente emparejada. El emparejamiento cifrado es el que aporta movimiento, temperatura de la piel, SpO₂ y frecuencia respiratoria: sin él, el sueño se calcula solo a partir de la frecuencia cardíaca. El zumbido, las alarmas y la sincronización del historial también lo necesitan."}},"fr":{"stringUnit":{"state":"translated","value":"La fréquence cardiaque en direct est transmise, mais votre bracelet n'est pas entièrement appairé. C'est l'appairage chiffré qui transporte le mouvement, la température cutanée, la SpO₂ et la fréquence respiratoire — sans lui, le sommeil est estimé à partir de la seule fréquence cardiaque. Le buzz, les alarmes et la synchronisation de l'historique en ont besoin aussi."}},"it":{"stringUnit":{"state":"translated","value":"La frequenza cardiaca live viene trasmessa, ma il tuo strap non è completamente associato. È l'associazione cifrata a trasportare movimento, temperatura della pelle, SpO₂ e frequenza respiratoria — senza di essa il sonno viene stimato dalla sola frequenza cardiaca. Anche buzz, sveglie e sincronizzazione della cronologia ne hanno bisogno."}},"pl":{"stringUnit":{"state":"translated","value":"Tętno na żywo jest przesyłane, ale opaska nie jest w pełni sparowana. To szyfrowane parowanie przenosi ruch, temperaturę skóry, SpO₂ i częstość oddechów — bez niego sen jest wyznaczany wyłącznie z tętna. Wibracje, alarmy i synchronizacja historii też go wymagają."}},"pt-PT":{"stringUnit":{"state":"translated","value":"A frequência cardíaca em direto está a ser transmitida, mas a sua pulseira não está totalmente emparelhada. É o emparelhamento encriptado que transporta movimento, temperatura da pele, SpO₂ e taxa respiratória — sem ele, o sono é calculado apenas a partir da frequência cardíaca. O zumbido, os alarmes e a sincronização do histórico também precisam dele."}},"ru":{"stringUnit":{"state":"translated","value":"Пульс передаётся в реальном времени, но ремешок сопряжён не полностью. Именно зашифрованное сопряжение передаёт движение, температуру кожи, SpO₂ и частоту дыхания — без него сон определяется только по пульсу. Вибрация, будильники и синхронизация истории тоже требуют его."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"实时心率正在传输,但你的手环尚未完全配对。加密配对才能传输动作、皮肤温度、SpO₂ 和呼吸频率——没有它,睡眠只能依据心率推算。震动、闹钟和历史同步同样需要它。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"即時心率正在傳輸,但你的手環尚未完全配對。加密配對才能傳輸動作、皮膚溫度、SpO₂ 和呼吸頻率——沒有它,睡眠只能依據心率推算。震動、鬧鐘和歷史同步同樣需要它。"}}}}, + "Clear conversation": {"localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Clear conversation"}}, + "de": {"stringUnit": {"state": "translated", "value": "Unterhaltung löschen"}}, + "es": {"stringUnit": {"state": "translated", "value": "Borrar conversación"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Effacer la conversation"}}, + "it": {"stringUnit": {"state": "translated", "value": "Cancella conversazione"}}, + "pl": {"stringUnit": {"state": "translated", "value": "Wyczyść rozmowę"}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Limpar conversa"}}, + "ru": {"stringUnit": {"state": "translated", "value": "Очистить разговор"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "清除对话"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "清除對話"}} + } }, + "Clear the saved conversation": {"localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Clear the saved conversation"}}, + "de": {"stringUnit": {"state": "translated", "value": "Gespeicherte Unterhaltung löschen"}}, + "es": {"stringUnit": {"state": "translated", "value": "Borrar la conversación guardada"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Effacer la conversation enregistrée"}}, + "it": {"stringUnit": {"state": "translated", "value": "Cancella la conversazione salvata"}}, + "pl": {"stringUnit": {"state": "translated", "value": "Wyczyść zapisaną rozmowę"}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Limpar a conversa guardada"}}, + "ru": {"stringUnit": {"state": "translated", "value": "Очистить сохранённый разговор"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "清除已保存的对话"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "清除已儲存的對話"}} + } }, + "Clear conversation?": {"localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Clear conversation?"}}, + "de": {"stringUnit": {"state": "translated", "value": "Unterhaltung löschen?"}}, + "es": {"stringUnit": {"state": "translated", "value": "¿Borrar conversación?"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Effacer la conversation ?"}}, + "it": {"stringUnit": {"state": "translated", "value": "Cancella conversazione?"}}, + "pl": {"stringUnit": {"state": "translated", "value": "Wyczyścić rozmowę?"}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Limpar conversa?"}}, + "ru": {"stringUnit": {"state": "translated", "value": "Очистить разговор?"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "清除对话?"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "清除對話?"}} + } }, + "This deletes the saved conversation from this device. Coach history is your own notes, not medical advice.": {"localizations": { + "en": {"stringUnit": {"state": "translated", "value": "This deletes the saved conversation from this device. Coach history is your own notes, not medical advice."}}, + "de": {"stringUnit": {"state": "translated", "value": "Dies löscht die gespeicherte Unterhaltung von diesem Gerät. Coach-Verlauf sind deine eigenen Notizen, kein medizinischer Rat."}}, + "es": {"stringUnit": {"state": "translated", "value": "Esto borra la conversación guardada de este dispositivo. El historial de Coach son tus propias notas, no consejo médico."}}, + "fr": {"stringUnit": {"state": "translated", "value": "Cela efface la conversation enregistrée de cet appareil. L'historique du Coach constitue vos propres notes, pas un avis médical."}}, + "it": {"stringUnit": {"state": "translated", "value": "Questo elimina la conversazione salvata da questo dispositivo. La cronologia di Coach sono i tuoi appunti, non consigli medici."}}, + "pl": {"stringUnit": {"state": "translated", "value": "Spowoduje to usunięcie zapisanej rozmowy z tego urządzenia. Historia Coach to Twoje własne notatki, a nie porada medyczna."}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Isto elimina a conversa guardada deste dispositivo. O histórico do Coach são as tuas próprias notas, não aconselhamento médico."}}, + "ru": {"stringUnit": {"state": "translated", "value": "Это удалит сохранённый разговор с этого устройства. История Коуча — это ваши собственные заметки, а не медицинская консультация."}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "这会从此设备删除已保存的对话。Coach 历史是你自己的笔记,不是医疗建议。"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "這會從此裝置刪除已儲存的對話。Coach 歷史是你自己的筆記,不是醫療建議。"}} + } }, + "Morning brief": {"localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Morning brief"}}, + "de": {"stringUnit": {"state": "translated", "value": "Morgenbrief"}}, + "es": {"stringUnit": {"state": "translated", "value": "Resumen matutino"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Bref du matin"}}, + "it": {"stringUnit": {"state": "translated", "value": "Riepilogo mattutino"}}, + "pl": {"stringUnit": {"state": "translated", "value": "Poranne podsumowanie"}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Resumo matinal"}}, + "ru": {"stringUnit": {"state": "translated", "value": "Утренняя сводка"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "晨间简报"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "晨間簡報"}} + } }, + "A local notification with today's readiness + training plan, generated on-device each morning.": {"localizations": { + "en": {"stringUnit": {"state": "translated", "value": "A local notification with today's readiness + training plan, generated on-device each morning."}}, + "de": {"stringUnit": {"state": "translated", "value": "Eine lokale Benachrichtigung mit heutiger Bereitschaft + Trainingsplan, jeden Morgen auf dem Gerät generiert."}}, + "es": {"stringUnit": {"state": "translated", "value": "Una notificación local con la disponibilidad de hoy + el plan de entrenamiento, generada en el dispositivo cada mañana."}}, + "fr": {"stringUnit": {"state": "translated", "value": "Une notification locale avec la disponibilité du jour + le plan d'entraînement, générée sur l'appareil chaque matin."}}, + "it": {"stringUnit": {"state": "translated", "value": "Una notifica locale con la disponibilità di oggi + il piano di allenamento, generata sul dispositivo ogni mattina."}}, + "pl": {"stringUnit": {"state": "translated", "value": "Lokalne powiadomienie z dzisiejszą gotowością + planem treningowym, generowane na urządzeniu każdego ranka."}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Uma notificação local com a disponibilidade de hoje + o plano de treino, gerada no dispositivo todas as manhãs."}}, + "ru": {"stringUnit": {"state": "translated", "value": "Локальное уведомление с сегодняшней готовностью + планом тренировок, генерируется на устройстве каждое утро."}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "一条包含今日准备度 + 训练计划的本地通知,每天早晨在设备上生成。"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "一條包含今日準備度 + 訓練計劃的本地通知,每天早晨在裝置上生成。"}} + } }, + "Off: nothing is generated or sent on a schedule.": {"localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Off: nothing is generated or sent on a schedule."}}, + "de": {"stringUnit": {"state": "translated", "value": "Aus: Es wird nichts generiert oder nach Zeitplan gesendet."}}, + "es": {"stringUnit": {"state": "translated", "value": "Desactivado: no se genera ni se envía nada según un horario."}}, + "fr": {"stringUnit": {"state": "translated", "value": "Désactivé : rien n'est généré ni envoyé selon un calendrier."}}, + "it": {"stringUnit": {"state": "translated", "value": "Off: non viene generato né inviato nulla in base a una pianificazione."}}, + "pl": {"stringUnit": {"state": "translated", "value": "Wyłączone: nic nie jest generowane ani wysyłane według harmonogramu."}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Desativado: nada é gerado ou enviado num agendamento."}}, + "ru": {"stringUnit": {"state": "translated", "value": "Выключено: ничего не генерируется и не отправляется по расписанию."}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "关闭:不会按计划生成或发送任何内容。"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "關閉:不會按計劃生成或發送任何內容。"}} + } }, + "Morning brief time": {"localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Morning brief time"}}, + "de": {"stringUnit": {"state": "translated", "value": "Morgenbrief-Zeit"}}, + "es": {"stringUnit": {"state": "translated", "value": "Hora del resumen matutino"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Heure du bref du matin"}}, + "it": {"stringUnit": {"state": "translated", "value": "Ora del riepilogo mattutino"}}, + "pl": {"stringUnit": {"state": "translated", "value": "Czas porannego podsumowania"}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Hora do resumo matinal"}}, + "ru": {"stringUnit": {"state": "translated", "value": "Время утренней сводки"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "晨间简报时间"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "晨間簡報時間"}} + } }, + "At %@, NOOP will use your key to generate today's brief. Best-effort: %@ decides exactly when a backgrounded app wakes.": {"localizations": { + "en": {"stringUnit": {"state": "translated", "value": "At %@, NOOP will use your key to generate today's brief. Best-effort: %@ decides exactly when a backgrounded app wakes."}}, + "de": {"stringUnit": {"state": "translated", "value": "Um %@ verwendet NOOP deinen Schlüssel, um den heutigen Brief zu generieren. Best-Effort: %@ entscheidet genau, wann eine Hintergrund-App aktiviert wird."}}, + "es": {"stringUnit": {"state": "translated", "value": "A las %@, NOOP usará tu clave para generar el resumen de hoy. Best-effort: %@ decide cuándo exactamente se activa una app en segundo plano."}}, + "fr": {"stringUnit": {"state": "translated", "value": "À %@, NOOP utilisera votre clé pour générer le bref du jour. Au mieux : %@ décide du moment exact où une app en arrière-plan se réveille."}}, + "it": {"stringUnit": {"state": "translated", "value": "Alle %@, NOOP userà la tua chiave per generare il riepilogo di oggi. Best-effort: %@ decide esattamente quando un'app in background si attiva."}}, + "pl": {"stringUnit": {"state": "translated", "value": "O %@ NOOP użyje Twojego klucza do wygenerowania dzisiejszego podsumowania. Best-effort: %@ decyduje, kiedy dokładnie aplikacja w tle się wybudzi."}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Às %@, o NOOP usará a tua chave para gerar o resumo de hoje. Best-effort: %@ decide exatamente quando uma app em segundo plano é ativada."}}, + "ru": {"stringUnit": {"state": "translated", "value": "В %@ NOOP использует ваш ключ для генерации сегодняшней сводки. По возможности: %@ решает, когда именно фоновое приложение пробуждается."}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "在 %@,NOOP 将使用你的密钥生成今日简报。尽力而为:%@ 决定后台应用何时唤醒。"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "在 %@,NOOP 將使用你的密鑰生成今日簡報。盡力而為:%@ 決定背景應用何時喚醒。"}} + } }, + "Send chart image to Gemini": {"localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Send chart image to Gemini"}}, + "de": {"stringUnit": {"state": "translated", "value": "Diagrammbild an Gemini senden"}}, + "es": {"stringUnit": {"state": "translated", "value": "Enviar imagen del gráfico a Gemini"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Envoyer l'image du graphique à Gemini"}}, + "it": {"stringUnit": {"state": "translated", "value": "Invia immagine del grafico a Gemini"}}, + "pl": {"stringUnit": {"state": "translated", "value": "Wyślij obraz wykresu do Gemini"}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Enviar imagem do gráfico para o Gemini"}}, + "ru": {"stringUnit": {"state": "translated", "value": "Отправить изображение графика в Gemini"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "将图表图像发送给 Gemini"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "將圖表影像傳送給 Gemini"}} + } }, + "On: a chart snapshot of your trends is sent with each question. Gemini can analyze the visual.": {"localizations": { + "en": {"stringUnit": {"state": "translated", "value": "On: a chart snapshot of your trends is sent with each question. Gemini can analyze the visual."}}, + "de": {"stringUnit": {"state": "translated", "value": "An: Bei jeder Frage wird ein Diagramm-Snapshot deiner Trends gesendet. Gemini kann das Visuelle analysieren."}}, + "es": {"stringUnit": {"state": "translated", "value": "Activado: se envía una captura del gráfico de tus tendencias con cada pregunta. Gemini puede analizar lo visual."}}, + "fr": {"stringUnit": {"state": "translated", "value": "Activé : un instantané du graphique de vos tendances est envoyé avec chaque question. Gemini peut analyser le visuel."}}, + "it": {"stringUnit": {"state": "translated", "value": "On: un'istantanea del grafico dei tuoi trend viene inviata con ogni domanda. Gemini può analizzare l'aspetto visivo."}}, + "pl": {"stringUnit": {"state": "translated", "value": "Włączone: z każdym pytaniem wysyłany jest zrzut wykresu Twoich trendów. Gemini może przeanalizować obraz."}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Ativado: uma captura do gráfico das tuas tendências é enviada com cada pergunta. O Gemini pode analisar a imagem."}}, + "ru": {"stringUnit": {"state": "translated", "value": "Вкл: снимок графика ваших трендов отправляется с каждым вопросом. Gemini может проанализировать визуальные данные."}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "开启:每次提问时会发送趋势图表快照。Gemini 可以分析视觉内容。"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "開啟:每次提問時會發送趨勢圖表快照。Gemini 可以分析視覺內容。"}} + } }, + "Off: only text is sent. Enable to let Gemini see your charts.": {"localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Off: only text is sent. Enable to let Gemini see your charts."}}, + "de": {"stringUnit": {"state": "translated", "value": "Aus: Es wird nur Text gesendet. Aktiviere dies, damit Gemini deine Diagramme sehen kann."}}, + "es": {"stringUnit": {"state": "translated", "value": "Desactivado: solo se envía texto. Actívalo para que Gemini vea tus gráficos."}}, + "fr": {"stringUnit": {"state": "translated", "value": "Désactivé : seul le texte est envoyé. Activez pour permettre à Gemini de voir vos graphiques."}}, + "it": {"stringUnit": {"state": "translated", "value": "Off: viene inviato solo testo. Attiva per permettere a Gemini di vedere i tuoi grafici."}}, + "pl": {"stringUnit": {"state": "translated", "value": "Wyłączone: wysyłany jest tylko tekst. Włącz, aby Gemini mógł zobaczyć Twoje wykresy."}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Desativado: apenas é enviado texto. Ativa para permitir que o Gemini veja os teus gráficos."}}, + "ru": {"stringUnit": {"state": "translated", "value": "Выкл: отправляется только текст. Включите, чтобы Gemini мог видеть ваши графики."}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "关闭:仅发送文本。启用以让 Gemini 查看你的图表。"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "關閉:僅發送文本。啟用以讓 Gemini 查看你的圖表。"}} + } }, + "Save to Journal": {"localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Save to Journal"}}, + "de": {"stringUnit": {"state": "translated", "value": "Im Tagebuch speichern"}}, + "es": {"stringUnit": {"state": "translated", "value": "Guardar en el diario"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Enregistrer dans le journal"}}, + "it": {"stringUnit": {"state": "translated", "value": "Salva nel diario"}}, + "pl": {"stringUnit": {"state": "translated", "value": "Zapisz w dzienniku"}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Guardar no diário"}}, + "ru": {"stringUnit": {"state": "translated", "value": "Сохранить в журнал"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "保存到日志"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "儲存到日誌"}} + } }, + "Follow-up prompt: %@": {"localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Follow-up prompt: %@"}}, + "de": {"stringUnit": {"state": "translated", "value": "Folge-Prompt: %@"}}, + "es": {"stringUnit": {"state": "translated", "value": "Pregunta de seguimiento: %@"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Question de suivi : %@"}}, + "it": {"stringUnit": {"state": "translated", "value": "Prompt di follow-up: %@"}}, + "pl": {"stringUnit": {"state": "translated", "value": "Prompt uzupełniający: %@"}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Pergunta de seguimento: %@"}}, + "ru": {"stringUnit": {"state": "translated", "value": "Дополнительный запрос: %@"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "后续提示:%@"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "後續提示:%@"}} + } }, + "~%lld tokens": {"localizations": { + "en": {"stringUnit": {"state": "translated", "value": "~%lld tokens"}}, + "de": {"stringUnit": {"state": "translated", "value": "~%lld Token"}}, + "es": {"stringUnit": {"state": "translated", "value": "~%lld tokens"}}, + "fr": {"stringUnit": {"state": "translated", "value": "~%lld jetons"}}, + "it": {"stringUnit": {"state": "translated", "value": "~%lld token"}}, + "pl": {"stringUnit": {"state": "translated", "value": "~%lld tokenów"}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "~%lld tokens"}}, + "ru": {"stringUnit": {"state": "translated", "value": "~%lld токенов"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "~%lld 个 token"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "~%lld 個 token"}} + } }, + "· may exceed small context windows": {"localizations": { + "en": {"stringUnit": {"state": "translated", "value": "· may exceed small context windows"}}, + "de": {"stringUnit": {"state": "translated", "value": "· kann kleine Kontextfenster überschreiten"}}, + "es": {"stringUnit": {"state": "translated", "value": "· puede superar ventanas de contexto pequeñas"}}, + "fr": {"stringUnit": {"state": "translated", "value": "· peut dépasser les petites fenêtres de contexte"}}, + "it": {"stringUnit": {"state": "translated", "value": "· potrebbe superare le piccole finestre di contesto"}}, + "pl": {"stringUnit": {"state": "translated", "value": "· może przekroczyć małe okna kontekstu"}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "· pode exceder janelas de contexto pequenas"}}, + "ru": {"stringUnit": {"state": "translated", "value": "· может превышать небольшие окна контекста"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "· 可能超出小型上下文窗口"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "· 可能超出小型上下文視窗"}} + } }, + "Ask out loud": {"localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Ask out loud"}}, + "de": {"stringUnit": {"state": "translated", "value": "Laut fragen"}}, + "es": {"stringUnit": {"state": "translated", "value": "Preguntar en voz alta"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Poser la question à voix haute"}}, + "it": {"stringUnit": {"state": "translated", "value": "Chiedi a voce alta"}}, + "pl": {"stringUnit": {"state": "translated", "value": "Zapytaj na głos"}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Perguntar em voz alta"}}, + "ru": {"stringUnit": {"state": "translated", "value": "Задать вопрос вслух"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "大声提问"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "大聲提問"}} + } }, + "Stop voice input": {"localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Stop voice input"}}, + "de": {"stringUnit": {"state": "translated", "value": "Spracheingabe stoppen"}}, + "es": {"stringUnit": {"state": "translated", "value": "Detener entrada de voz"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Arrêter la saisie vocale"}}, + "it": {"stringUnit": {"state": "translated", "value": "Ferma input vocale"}}, + "pl": {"stringUnit": {"state": "translated", "value": "Zatrzymaj wprowadzanie głosowe"}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Parar entrada de voz"}}, + "ru": {"stringUnit": {"state": "translated", "value": "Остановить голосовой ввод"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "停止语音输入"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "停止語音輸入"}} + } }, + "Voice input": {"localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Voice input"}}, + "de": {"stringUnit": {"state": "translated", "value": "Spracheingabe"}}, + "es": {"stringUnit": {"state": "translated", "value": "Entrada de voz"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Saisie vocale"}}, + "it": {"stringUnit": {"state": "translated", "value": "Input vocale"}}, + "pl": {"stringUnit": {"state": "translated", "value": "Wprowadzanie głosowe"}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Entrada de voz"}}, + "ru": {"stringUnit": {"state": "translated", "value": "Голосовой ввод"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "语音输入"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "語音輸入"}} + } }, + "Coach Brief": {"localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Coach Brief"}}, + "de": {"stringUnit": {"state": "translated", "value": "Coach-Brief"}}, + "es": {"stringUnit": {"state": "translated", "value": "Resumen del Coach"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Bref du Coach"}}, + "it": {"stringUnit": {"state": "translated", "value": "Riepilogo Coach"}}, + "pl": {"stringUnit": {"state": "translated", "value": "Podsumowanie Coach"}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Resumo do Coach"}}, + "ru": {"stringUnit": {"state": "translated", "value": "Сводка Коуча"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "Coach 简报"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "Coach 簡報"}} + } }, + "No brief yet": {"localizations": { + "en": {"stringUnit": {"state": "translated", "value": "No brief yet"}}, + "de": {"stringUnit": {"state": "translated", "value": "Noch kein Brief"}}, + "es": {"stringUnit": {"state": "translated", "value": "Aún no hay resumen"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Pas encore de bref"}}, + "it": {"stringUnit": {"state": "translated", "value": "Nessun riepilogo ancora"}}, + "pl": {"stringUnit": {"state": "translated", "value": "Brak podsumowania"}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Ainda sem resumo"}}, + "ru": {"stringUnit": {"state": "translated", "value": "Сводки пока нет"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "尚无简报"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "尚無簡報"}} + } }, + "Enable Morning Brief in Coach settings to see today's readiness here.": {"localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Enable Morning Brief in Coach settings to see today's readiness here."}}, + "de": {"stringUnit": {"state": "translated", "value": "Aktiviere den Morgenbrief in den Coach-Einstellungen, um die heutige Bereitschaft hier zu sehen."}}, + "es": {"stringUnit": {"state": "translated", "value": "Activa el resumen matutino en los ajustes de Coach para ver la disponibilidad de hoy aquí."}}, + "fr": {"stringUnit": {"state": "translated", "value": "Activez le bref du matin dans les réglages du Coach pour voir la disponibilité du jour ici."}}, + "it": {"stringUnit": {"state": "translated", "value": "Attiva il riepilogo mattutino nelle impostazioni di Coach per vedere la disponibilità di oggi qui."}}, + "pl": {"stringUnit": {"state": "translated", "value": "Włącz poranne podsumowanie w ustawieniach Coach, aby zobaczyć tu dzisiejszą gotowość."}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Ativa o resumo matinal nas definições do Coach para ver a disponibilidade de hoje aqui."}}, + "ru": {"stringUnit": {"state": "translated", "value": "Включите утреннюю сводку в настройках Коуча, чтобы видеть сегодняшнюю готовность здесь."}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "在 Coach 设置中启用晨间简报以在此处查看今日准备度。"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "在 Coach 設定中啟用晨間簡報以在此處查看今日準備度。"}} + } }, + "Voice input unavailable. Check microphone permission in Settings.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Spracheingabe nicht verfügbar. Prüfe die Mikrofonberechtigung in den Einstellungen."}}, + "en": {"stringUnit": {"state": "translated", "value": "Voice input unavailable. Check microphone permission in Settings."}}, + "es": {"stringUnit": {"state": "translated", "value": "Entrada de voz no disponible. Comprueba el permiso del micrófono en Ajustes."}}, + "fr": {"stringUnit": {"state": "translated", "value": "Saisie vocale indisponible. Vérifiez l'autorisation du microphone dans Réglages."}}, + "it": {"stringUnit": {"state": "translated", "value": "Input vocale non disponibile. Controlla l'autorizzazione del microfono in Impostazioni."}}, + "pl": {"stringUnit": {"state": "translated", "value": "Wprowadzanie głosowe niedostępne. Sprawdź uprawnienie mikrofonu w Ustawieniach."}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Entrada de voz indisponível. Verifica a permissão do microfone nas Definições."}}, + "ru": {"stringUnit": {"state": "translated", "value": "Голосовой ввод недоступен. Проверьте разрешение микрофона в Настройках."}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "语音输入不可用。请在“设置”中检查麦克风权限。"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "語音輸入無法使用。請在「設定」中檢查麥克風權限。"}} + } }, + "On-device speech not available for %@.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Geräteinterne Spracherkennung für %@ nicht verfügbar."}}, + "en": {"stringUnit": {"state": "translated", "value": "On-device speech not available for %@."}}, + "es": {"stringUnit": {"state": "translated", "value": "El reconocimiento de voz en el dispositivo no está disponible para %@."}}, + "fr": {"stringUnit": {"state": "translated", "value": "La reconnaissance vocale sur l'appareil n'est pas disponible pour %@."}}, + "it": {"stringUnit": {"state": "translated", "value": "Il riconoscimento vocale sul dispositivo non è disponibile per %@."}}, + "pl": {"stringUnit": {"state": "translated", "value": "Rozpoznawanie mowy na urządzeniu jest niedostępne dla %@."}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "O reconhecimento de voz no dispositivo não está disponível para %@."}}, + "ru": {"stringUnit": {"state": "translated", "value": "Распознавание речи на устройстве недоступно для %@."}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "设备端语音识别不支持 %@。"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "裝置端語音辨識不支援 %@。"}} + } }, + "Couldn't start the microphone: %@": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Mikrofon konnte nicht gestartet werden: %@"}}, + "en": {"stringUnit": {"state": "translated", "value": "Couldn't start the microphone: %@"}}, + "es": {"stringUnit": {"state": "translated", "value": "No se pudo iniciar el micrófono: %@"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Impossible de démarrer le microphone : %@"}}, + "it": {"stringUnit": {"state": "translated", "value": "Impossibile avviare il microfono: %@"}}, + "pl": {"stringUnit": {"state": "translated", "value": "Nie udało się uruchomić mikrofonu: %@"}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Não foi possível iniciar o microfone: %@"}}, + "ru": {"stringUnit": {"state": "translated", "value": "Не удалось включить микрофон: %@"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "无法启动麦克风:%@"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "無法啟動麥克風:%@"}} + } }, + "%lld of %lld nights": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%lld von %lld Nächten"}}, + "en": {"stringUnit": {"state": "translated", "value": "%lld of %lld nights"}}, + "es": {"stringUnit": {"state": "translated", "value": "%lld de %lld noches"}}, + "fr": {"stringUnit": {"state": "translated", "value": "%lld nuits sur %lld"}}, + "it": {"stringUnit": {"state": "translated", "value": "%lld di %lld notti"}}, + "pl": {"stringUnit": {"state": "translated", "value": "%lld z %lld nocy"}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "%lld de %lld noites"}}, + "ru": {"stringUnit": {"state": "translated", "value": "%lld из %lld ночей"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "%lld / %lld 晚"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "%lld / %lld 晚"}} + } }, + "%lld of the last %lld nights recorded no HRV. Check the strap is worn overnight and syncing.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%lld der letzten %lld Nächte haben keine HRV aufgezeichnet. Prüfe, ob der Strap nachts getragen wird und synchronisiert."}}, + "en": {"stringUnit": {"state": "translated", "value": "%lld of the last %lld nights recorded no HRV. Check the strap is worn overnight and syncing."}}, + "es": {"stringUnit": {"state": "translated", "value": "%lld de las últimas %lld noches no registraron HRV. Comprueba que llevas la banda por la noche y que se sincroniza."}}, + "fr": {"stringUnit": {"state": "translated", "value": "%lld des %lld dernières nuits n’ont enregistré aucune VFC. Vérifiez que le bracelet est porté la nuit et se synchronise."}}, + "it": {"stringUnit": {"state": "translated", "value": "%lld delle ultime %lld notti non hanno registrato HRV. Controlla che la fascia sia indossata di notte e si sincronizzi."}}, + "pl": {"stringUnit": {"state": "translated", "value": "%lld z ostatnich %lld nocy nie zarejestrowało HRV. Sprawdź, czy opaska jest noszona w nocy i synchronizuje się."}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "%lld das últimas %lld noites não registaram HRV. Verifica se usas a fita à noite e se sincroniza."}}, + "ru": {"stringUnit": {"state": "translated", "value": "%lld из последних %lld ночей не записали ВСР. Проверьте, что ремешок надет ночью и синхронизируется."}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "最近 %2$lld 晚中有 %1$lld 晚未记录 HRV。请检查夜间是否佩戴并已同步。"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "最近 %2$lld 晚中有 %1$lld 晚未記錄 HRV。請檢查夜間是否配戴並已同步。"}} + } }, + "Re-derives the absolute °C for nights the 21-night rescore window never reaches, using the same funnel as the scoring pass. Fill-only: it never overwrites a measured value. Watch the strap log for the result.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Leitet das absolute °C für Nächte neu ab, die das 21-Nächte-Fenster nie erreicht, mit demselben Funnel wie der Scoring-Durchlauf. Nur Auffüllen: ein gemessener Wert wird nie überschrieben. Das Ergebnis steht im Strap-Log."}}, + "en": {"stringUnit": {"state": "translated", "value": "Re-derives the absolute °C for nights the 21-night rescore window never reaches, using the same funnel as the scoring pass. Fill-only: it never overwrites a measured value. Watch the strap log for the result."}}, + "es": {"stringUnit": {"state": "translated", "value": "Vuelve a derivar el °C absoluto de las noches que la ventana de 21 noches nunca alcanza, con el mismo embudo que la pasada de puntuación. Solo rellena: nunca sobrescribe un valor medido. Consulta el resultado en el registro de la banda."}}, + "fr": {"stringUnit": {"state": "translated", "value": "Recalcule le °C absolu des nuits que la fenêtre de 21 nuits n’atteint jamais, avec le même entonnoir que la passe de calcul. Remplissage seul : une valeur mesurée n’est jamais écrasée. Le résultat s’affiche dans le journal du bracelet."}}, + "it": {"stringUnit": {"state": "translated", "value": "Ricalcola il °C assoluto per le notti che la finestra di 21 notti non raggiunge mai, con lo stesso funnel della passata di calcolo. Solo riempimento: non sovrascrive mai un valore misurato. Il risultato è nel log della fascia."}}, + "pl": {"stringUnit": {"state": "translated", "value": "Ponownie wylicza bezwzględne °C dla nocy, których nie obejmuje okno 21 nocy, tym samym lejkiem co przebieg oceniający. Tylko uzupełnia: nigdy nie nadpisuje zmierzonej wartości. Wynik znajdziesz w dzienniku opaski."}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Volta a derivar o °C absoluto das noites que a janela de 21 noites nunca alcança, com o mesmo funil da passagem de pontuação. Apenas preenche: nunca substitui um valor medido. Vê o resultado no registo da fita."}}, + "ru": {"stringUnit": {"state": "translated", "value": "Пересчитывает абсолютные °C для ночей, до которых не доходит окно в 21 ночь, тем же алгоритмом, что и обычный расчёт. Только заполняет: измеренное значение никогда не перезаписывается. Результат смотрите в журнале ремешка."}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "用与评分流程相同的漏斗,为 21 晚窗口未覆盖的夜晚重新推导绝对温度(°C)。仅填充:绝不覆盖已测量的值。结果见腕带日志。"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "以與評分流程相同的漏斗,為 21 晚視窗未涵蓋的夜晚重新推導絕對溫度(°C)。僅填充:絕不覆寫已測量的值。結果見腕帶日誌。"}} } } }, "version": "1.0" -} +} \ No newline at end of file diff --git a/Strand/Screens/AddDeviceWizard.swift b/Strand/Screens/AddDeviceWizard.swift index d885560651..6f70fc91dc 100644 --- a/Strand/Screens/AddDeviceWizard.swift +++ b/Strand/Screens/AddDeviceWizard.swift @@ -327,7 +327,7 @@ struct AddDeviceWizard: View { VStack(alignment: .leading, spacing: 10) { typeRow(.whoop5mg, icon: "applewatch.side.right", title: "WHOOP 5.0 / MG", - subtitle: String(localized: "Newer WHOOP band, experimental in NOOP")) + subtitle: String(localized: "Newer WHOOP band with live data and history sync")) typeRow(.whoop4, icon: "applewatch.side.right", title: "WHOOP 4.0", subtitle: String(localized: "NOOP's primary, fully-supported band")) @@ -1430,7 +1430,7 @@ struct AddDeviceWizard: View { Image(systemName: "flask") .foregroundStyle(StrandPalette.statusWarning) .accessibilityHidden(true) - Text("WHOOP 5.0 / MG support is newer and still experimental in NOOP.") + Text("WHOOP 5.0 / MG supports live data and strap history. Protocol-research tools are available separately in Test Centre.") .font(StrandFont.footnote) .foregroundStyle(StrandPalette.statusWarning) .fixedSize(horizontal: false, vertical: true) diff --git a/Strand/Screens/AutoWorkoutCard.swift b/Strand/Screens/AutoWorkoutCard.swift index 1f885a9053..8938320204 100644 --- a/Strand/Screens/AutoWorkoutCard.swift +++ b/Strand/Screens/AutoWorkoutCard.swift @@ -131,13 +131,9 @@ struct AutoWorkoutCard: View { } /// HH:mm in the user's locale/timezone. - private static let timeFmt: DateFormatter = { - let f = DateFormatter() - f.locale = AppLanguage.activeLocale - f.timeStyle = .short - f.dateStyle = .none - return f - }() + /// #1821: routed through AppClock so the Clock format setting reaches this label. Was a `static + /// let`, which would have frozen the reader's choice at first use until the app relaunched. + private static var timeFmt: DateFormatter { AppClock.hourMinuteFormatter() } /// Localized medium date ("Jun 23, 2026") for a bout older than yesterday. private static let dateFmt: DateFormatter = { diff --git a/Strand/Screens/BackupSyncView.swift b/Strand/Screens/BackupSyncView.swift index 599aa35942..9abcd24bfb 100644 --- a/Strand/Screens/BackupSyncView.swift +++ b/Strand/Screens/BackupSyncView.swift @@ -270,7 +270,14 @@ struct BackupSyncView: View { alertMessage = String(localized: "Fully quit and reopen NOOP to load it.") case .failure(let m): alertTitle = String(localized: "Restore problem"); alertMessage = m - case .cancelled, .exported: + case .restoreTooLarge(let name, let limit): + // #1807: recoverable, but not from here — this view restores a snapshot directly and + // has no confirm step to hang the override on. Point at the path that does, rather + // than leaving the user with a refusal and nowhere to go. + let cap = ByteCountFormatter.string(fromByteCount: limit, countStyle: .file) + alertTitle = String(localized: "Backup problem") + alertMessage = String(localized: "\(name) is larger than the \(cap) NOOP restores without asking. You can still restore it from Settings → Backup & restore → Import, which will ask you to confirm.") + case .cancelled, .exported, .exportedOversize: alertTitle = String(localized: "Restore problem"); alertMessage = String(localized: "Couldn't restore that backup.") } showAlert = true @@ -287,6 +294,8 @@ struct BackupSyncView: View { private func absoluteTime(_ ms: Int) -> String { let f = DateFormatter() + // #1821: localized DATE style untouched; only the hour cycle is the reader's. + f.locale = AppClock.formattingLocale f.dateStyle = .medium f.timeStyle = .short return f.string(from: Date(timeIntervalSince1970: Double(ms) / 1000.0)) @@ -355,6 +364,9 @@ private struct RestorePickerSheet: View { private func absoluteTime(_ ms: Int) -> String { let f = DateFormatter() + // #1821: localized DATE style untouched; only the hour cycle is the reader's. Second copy of + // this helper in the file - a single-shot replace fixed only the first, which the sweep caught. + f.locale = AppClock.formattingLocale f.dateStyle = .medium f.timeStyle = .short return f.string(from: Date(timeIntervalSince1970: Double(ms) / 1000.0)) diff --git a/Strand/Screens/BodyClockDialCard.swift b/Strand/Screens/BodyClockDialCard.swift new file mode 100644 index 0000000000..28e35ebd4d --- /dev/null +++ b/Strand/Screens/BodyClockDialCard.swift @@ -0,0 +1,257 @@ +import SwiftUI +import StrandAnalytics +import StrandDesign + +/// A 24 h dial comparing the night actually slept against the chronotype-ideal window (#1680). +/// +/// Two concentric arcs on one ring: the outer is where the body clock wanted the night, the inner is +/// where it happened. Overlap is the whole message — the card exists so "was last night's timing right" +/// is answerable at a glance, which the existing text-only `BodyClockCard` on Health cannot do. +/// +/// VOCABULARY. The caption measures `sleepWindowOffsetHours` — the distance between the two ARCS DRAWN — +/// and NOT `offsetVsScheduleMinutes`, which compares the clock to the wearer's habitual schedule and is +/// what the Health card already reports. The two disagree exactly when someone keeps a consistent +/// schedule that does not suit their clock, and that is the case this dial exists to show, so captioning +/// it with the other number would contradict the picture. +/// +/// Nothing here computes a metric: the window, the offset and the chronotype all come from +/// `CircadianEngine`, byte-identical with the Kotlin twin. Only the drawing is per-platform. +struct BodyClockDialCard: View { + let estimate: CircadianEngine.PhaseEstimate + /// The night's own bed/wake clock hours (0..<24, fractional), from the scored session. + let actualBedHour: Double + let actualWakeHour: Double + + // One hue for both arcs, told apart by dash and weight rather than by a second colour. Two blues + // competed with the background image; a single legible one plus a dashed, lighter reference does not. + private var hue: Color { StrandPalette.restLine } + + /// The night's length, taken the long way round the clock when it crosses midnight. + private var durationHours: Double { + let d = (actualWakeHour - actualBedHour).truncatingRemainder(dividingBy: 24) + return d <= 0 ? d + 24 : d + } + + private var ideal: (bedHour: Double, wakeHour: Double)? { + CircadianEngine.idealSleepWindow(tempMinHour: estimate.tempMinHour, durationHours: durationHours) + } + + private var offsetHours: Double { + CircadianEngine.sleepWindowOffsetHours(tempMinHour: estimate.tempMinHour, + actualWakeHour: actualWakeHour) + } + + var body: some View { + // `ideal` is nil exactly when the night's length is non-positive or a full day — the same input + // that makes `sweep` wrap to 24 h and draw the actual arc as a complete ring. Rendering a full + // circle with no ideal arc beside it would state something false about the night, so the card + // stands down instead. Twin of the Kotlin guard. + if ideal != nil { + NoopCard(tint: hue) { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + header + dial + .frame(maxWidth: .infinity) + .accessibilityElement(children: .ignore) + // Label only. The verdict is the caption Text below, a separate element, so giving + // the dial the same string as its VALUE made VoiceOver announce it twice. + .accessibilityLabel(Text("Body clock dial")) + legend + Text(alignmentText) + .font(StrandFont.title2) + .foregroundStyle(StrandPalette.textPrimary) + .fixedSize(horizontal: false, vertical: true) + if let chronotype = CircadianEngine.chronotype(estimate) { + Text(chronotypeText(chronotype)) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + } + } + } + .accessibilityElement(children: .contain) + } + } + + private var header: some View { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text("Body clock").strandOverline() + Text("Last night against your clock") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + } + Spacer() + } + } + + /// Midnight at the top, clocking round to the right — the orientation every 24 h dial uses, so the + /// ring reads without a legend. SwiftUI's zero angle is at 3 o'clock, hence the −90. + private func angle(_ hour: Double) -> Angle { .degrees(hour / 24 * 360 - 90) } + + /// Sweep from `from` to `to` going clockwise, always positive so an arc crossing midnight still draws. + private func sweep(_ from: Double, _ to: Double) -> Double { + let d = (to - from).truncatingRemainder(dividingBy: 24) + return d <= 0 ? d + 24 : d + } + + /// The ring geometry, resolved once. Explicitly typed and hoisted out of the drawing closure: an + /// 84-line `Canvas` body of inferred `CGFloat` arithmetic exceeded the Swift type-checker's budget + /// and failed the macOS build outright ("unable to type-check this expression in reasonable time"), + /// which only app-build compiles — the same trap `DevicesView`'s gates hit. + private struct DialGeometry { + let centre: CGPoint + let outer: CGFloat + let inner: CGFloat + + init(size: CGSize) { + let side: CGFloat = min(size.width, size.height) + centre = CGPoint(x: size.width / 2, y: size.height / 2) + outer = side / 2 - 10 + inner = (side / 2 - 10) - 16 + } + } + + private var dial: some View { + Canvas { ctx, size in + let g = DialGeometry(size: size) + drawTrack(ctx, g) + drawTicks(ctx, g) + drawArcs(ctx, g) + drawOnset(ctx, g) + } + .frame(height: 170) + } + + /// A full-circle TRACK under the night arc, the same idiom `RecoveryRing` uses: a faint + /// `surfaceInset` band with the live arc drawn on top. A one-point hairline left the dial reading as + /// a thin wireframe against a busy background; a real track gives the ring presence and makes the + /// highlighted segment obvious as a portion of a whole day. The hairline stays at the reference + /// radius so the dashed arc has a circle to belong to. + private func drawTrack(_ ctx: GraphicsContext, _ g: DialGeometry) { + let trackRect = CGRect(x: g.centre.x - g.inner, y: g.centre.y - g.inner, + width: g.inner * 2, height: g.inner * 2) + ctx.stroke(Path(ellipseIn: trackRect), with: .color(StrandPalette.surfaceInset), + style: StrokeStyle(lineWidth: 9, lineCap: .round)) + let rimRect = CGRect(x: g.centre.x - g.outer, y: g.centre.y - g.outer, + width: g.outer * 2, height: g.outer * 2) + ctx.stroke(Path(ellipseIn: rimRect), with: .color(StrandPalette.hairline), lineWidth: 1) + } + + /// Six-hourly ticks, with MIDNIGHT drawn longer and brighter. Four identical marks at 90 degrees + /// orient nothing — the ring is symmetric under rotation as far as they are concerned, so a reader + /// cannot tell midnight from noon and the arcs become unplaceable. One distinguished mark anchors the + /// whole dial, and does it without text, keeping the card clear of a 12-versus-24-hour format question. + private func drawTicks(_ ctx: GraphicsContext, _ g: DialGeometry) { + for tick in stride(from: 0.0, to: 24.0, by: 6.0) { + let isMidnight: Bool = tick == 0 + let a: Double = angle(tick).radians + let len: CGFloat = isMidnight ? 9 : 4 + let cosA: CGFloat = cos(a) + let sinA: CGFloat = sin(a) + var p = Path() + p.move(to: CGPoint(x: g.centre.x + cosA * (g.outer - len), + y: g.centre.y + sinA * (g.outer - len))) + p.addLine(to: CGPoint(x: g.centre.x + cosA * g.outer, y: g.centre.y + sinA * g.outer)) + let tint: Color = isMidnight ? StrandPalette.textSecondary + : StrandPalette.textTertiary.opacity(0.5) + ctx.stroke(p, with: .color(tint), lineWidth: isMidnight ? 1.5 : 1) + } + } + + /// The two arcs differ by PATTERN as well as weight. Opacity alone was the first cut and it does not + /// survive the card being translucent over a custom background image — the reference arc washed out + /// to near-invisible on a real device, losing the comparison the card exists for. BUTT caps on the + /// dashed stroke: a round cap adds lineWidth/2 at EACH end of EVERY dash, so at a 7 pt stroke a + /// [2, 7] dash renders as 9 pt of ink with a 0 pt gap — a line that looks solid while claiming to be + /// dashed, the one outcome this must not produce. + private func drawArcs(_ ctx: GraphicsContext, _ g: DialGeometry) { + if let ideal { + strokeArc(ctx, g, radius: g.outer, from: ideal.bedHour, to: ideal.wakeHour, + colour: hue.opacity(0.55), width: 7, dashed: true) + } + strokeArc(ctx, g, radius: g.inner, from: actualBedHour, to: actualWakeHour, + colour: hue, width: 9, dashed: false) + } + + private func strokeArc(_ ctx: GraphicsContext, _ g: DialGeometry, radius: CGFloat, + from: Double, to: Double, colour: Color, width: CGFloat, dashed: Bool) { + let startAngle: Angle = angle(from) + let sweepDegrees: Double = sweep(from, to) / 24 * 360 + var p = Path() + p.addArc(center: g.centre, radius: radius, startAngle: startAngle, + endAngle: .degrees(startAngle.degrees + sweepDegrees), clockwise: false) + let dash: [CGFloat] = dashed ? [3, 5] : [] + ctx.stroke(p, with: .color(colour), + style: StrokeStyle(lineWidth: width, lineCap: dashed ? .butt : .round, dash: dash)) + } + + /// A bed at sleep ONSET. Without it the night arc has two indistinguishable ends and the reader must + /// work out which way round the day runs before the picture means anything — the marker turns + /// "somewhere in this band" into "it started here". Drawn INTO A RECT, not at a point: `draw(_:at:)` + /// renders an SF Symbol at its intrinsic size, which follows the environment font and would not match + /// the Kotlin twin's fixed 14 dp glyph. + private func drawOnset(_ ctx: GraphicsContext, _ g: DialGeometry) { + var bed = ctx.resolve(Image(systemName: "bed.double.fill")) + bed.shading = .color(hue) + let onset: Double = angle(actualBedHour).radians + let glyph: CGFloat = 14 + let x: CGFloat = g.centre.x + cos(onset) * g.inner - glyph / 2 + let y: CGFloat = g.centre.y + sin(onset) * g.inner - glyph / 2 + ctx.draw(bed, in: CGRect(x: x, y: y, width: glyph, height: glyph)) + } + + /// Which arc is which. Without this the card shows two bands and no way to tell them apart — + /// "outer means ideal" is an arbitrary choice, not something a reader can infer. The swatches are + /// drawn with the SAME stroke style as the arcs so the mapping cannot drift apart from the drawing. + private var legend: some View { + HStack(spacing: 14) { + legendItem(colour: hue, dashed: false, label: String(localized: "Last night")) + legendItem(colour: hue.opacity(0.55), dashed: true, + label: String(localized: "Your clock")) + Spacer() + } + .accessibilityHidden(true) // the caption below already states the comparison in words + } + + private func legendItem(colour: Color, dashed: Bool, label: String) -> some View { + HStack(spacing: 5) { + Canvas { ctx, size in + var p = Path() + p.move(to: CGPoint(x: 0, y: size.height / 2)) + p.addLine(to: CGPoint(x: size.width, y: size.height / 2)) + ctx.stroke(p, with: .color(colour), + style: StrokeStyle(lineWidth: 3, lineCap: dashed ? .butt : .round, + dash: dashed ? [2, 3] : [])) + } + .frame(width: 18, height: 4) + Text(label) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + } + } + + /// Rounded to five minutes: the underlying phase is an activity fit, so a to-the-minute caption would + /// imply a precision the estimate does not carry. + private var alignmentText: String { + let minutes = Int((offsetHours * 60 / 5).rounded()) * 5 + if abs(minutes) < 30 { return String(localized: "In sync with your body clock") } + let hours = abs(Double(minutes)) / 60 + // Locale-formatted, NOT String(format:). That is C-locale, so it prints "1.5 h" for a German + // reader while the Android twin's String.format prints "1,5 h" from the default locale — the two + // platforms disagreeing on a decimal separator in the same sentence. + let amount = hours >= 1 + ? "\(hours.formatted(.number.precision(.fractionLength(1)))) h" + : "\(abs(minutes)) min" + return minutes > 0 + ? String(localized: "\(amount) later than your body clock") + : String(localized: "\(amount) earlier than your body clock") + } + + private func chronotypeText(_ c: CircadianEngine.Chronotype) -> String { + switch c { + case .morning: return String(localized: "Morning type") + case .intermediate: return String(localized: "Intermediate type") + case .evening: return String(localized: "Evening type") + } + } +} diff --git a/Strand/Screens/CaffeineLogCard.swift b/Strand/Screens/CaffeineLogCard.swift index 4cdd835796..383728f2a6 100644 --- a/Strand/Screens/CaffeineLogCard.swift +++ b/Strand/Screens/CaffeineLogCard.swift @@ -201,12 +201,9 @@ struct CaffeineLogCard: View { return Self.cutoffTimeFormatter.string(from: date) } - private static let cutoffTimeFormatter: DateFormatter = { - let f = DateFormatter() - f.timeStyle = .short - f.dateStyle = .none - return f - }() + /// #1821: routed through AppClock so the Clock format setting reaches this label. Was a `static + /// let`, which would have frozen the reader's choice at first use until the app relaunched. + private static var cutoffTimeFormatter: DateFormatter { AppClock.hourMinuteFormatter() } // MARK: - Active hint @@ -335,10 +332,7 @@ struct CaffeineLogCard: View { .buttonStyle(.plain) } - private static let timeFormatter: DateFormatter = { - let f = DateFormatter() - f.timeStyle = .short - f.dateStyle = .none - return f - }() + /// #1821: routed through AppClock so the Clock format setting reaches this label. Was a `static + /// let`, which would have frozen the reader's choice at first use until the app relaunched. + private static var timeFormatter: DateFormatter { AppClock.hourMinuteFormatter() } } diff --git a/Strand/Screens/CoachLauncherSheet.swift b/Strand/Screens/CoachLauncherSheet.swift new file mode 100644 index 0000000000..e43ec758e6 --- /dev/null +++ b/Strand/Screens/CoachLauncherSheet.swift @@ -0,0 +1,146 @@ +import SwiftUI +import StrandDesign + +/// The compact Coach launcher opened from the optional Today card (#1862). +/// +/// Coach is otherwise reachable only through More/Insights, which makes it easy to miss and means +/// leaving Today to try it. This is the shortcut — and deliberately ONLY a shortcut. +/// +/// It owns no send, stream, error or consent surface of its own. Picking a suggestion or submitting the +/// composer hands the question to `AICoachEngine.pendingPrompt` and routes to `CoachView`, which already +/// has all of that. A second chat UI would drift from the first, and the issue explicitly defers the +/// persistent-workspace redesign (threads, retention, backup policy) to a follow-up. +/// +/// NO PROVIDER REQUEST IS MADE BY OPENING THIS. Everything shown is local: `isConfigured` reads the +/// stored key, and the prompts are static copy. The first network call still happens where it always +/// did — inside `AICoachEngine.send`, after an explicit user action. +struct CoachLauncherSheet: View { + @EnvironmentObject var coach: AICoachEngine + @EnvironmentObject var router: NavRouter + @Environment(\.dismiss) private var dismiss + + @State private var draft = "" + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if coach.isConfigured { + configured + } else { + unconfigured + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(StrandPalette.surfaceBase.ignoresSafeArea()) + .navigationTitle(Text("Coach")) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + #if os(iOS) + .presentationDetents([.medium, .large]) + #endif + } + + // MARK: Configured — suggestions + a compact composer + + @ViewBuilder + private var configured: some View { + Text("Ask about your charge, effort, rest and workouts, grounded in your own numbers.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + + // Deliberately no "Try asking" heading: the line above already frames the list, and a new + // literal would be the only string in this card needing translation into ten locales. The whole + // launcher reuses copy the Coach screen already ships. + + ForEach(CoachPrompts.suggestions, id: \.self) { prompt in + Button { hand(off: prompt) } label: { + Text(prompt) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textPrimary) + .padding(.horizontal, 12).padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(FrostedCardSurface(cornerRadius: NoopMetrics.cardRadius)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(Text("Suggested prompt: \(prompt)")) + } + + HStack(spacing: 8) { + TextField("Ask your coach…", text: $draft, axis: .vertical) + .textFieldStyle(.plain) + .lineLimit(1...3) + .padding(.horizontal, 12).padding(.vertical, 9) + .background(FrostedCardSurface(cornerRadius: NoopMetrics.cardRadius)) + .onSubmit { submitDraft() } + Button { + submitDraft() + } label: { + Image(systemName: "arrow.up.circle.fill") + .font(.system(size: 26, weight: .semibold)) + .foregroundStyle(StrandPalette.accent) + } + .buttonStyle(.plain) + .disabled(trimmedDraft.isEmpty) + .accessibilityLabel(Text("Send")) + } + .padding(.top, 4) + } + + // MARK: Not configured — explain the opt-in and route to the existing setup + + @ViewBuilder + private var unconfigured: some View { + // The SAME explanation the Coach screen shows, so the bring-your-own-key model is described + // once. The button routes to that screen, which stays the only place a key is entered. + Text("Coach uses your own API key. Pick a provider, paste a key, and choose a model. Your key is stored securely in the Keychain and never leaves \(Platform.deviceNounPhrase) except as the request you make.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + + Button { + dismiss() + router.openCoach() + } label: { + Text("Connect a provider") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textPrimary) + .padding(.horizontal, 14).padding(.vertical, 10) + .frame(maxWidth: .infinity) + .background(FrostedCardSurface(cornerRadius: NoopMetrics.cardRadius)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + // MARK: Handoff + + private var trimmedDraft: String { + draft.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func submitDraft() { + let text = trimmedDraft + guard !text.isEmpty else { return } + hand(off: text) + } + + /// Dismiss, park the question, and open Coach. Deliberately NOT `coach.send` from here: the launcher + /// never performs a provider request, so a user who opens this sheet and changes their mind has cost + /// nothing and sent nothing. + private func hand(off prompt: String) { + coach.pendingPrompt = prompt + draft = "" + dismiss() + router.openCoach() + } +} diff --git a/Strand/Screens/CoachPrompts.swift b/Strand/Screens/CoachPrompts.swift new file mode 100644 index 0000000000..bcbe96388e --- /dev/null +++ b/Strand/Screens/CoachPrompts.swift @@ -0,0 +1,17 @@ +import Foundation + +/// The suggested questions offered when a Coach thread is empty (#1862). +/// +/// Extracted from `CoachView` so the Today launcher sheet and the full screen offer the SAME four +/// prompts. Two hardcoded lists would have drifted the moment either was edited, and the launcher's +/// whole purpose is to be a shortcut into the screen rather than a second, subtly different Coach. +/// +/// The strings are unchanged and keep their existing String Catalog keys, so nothing needs retranslating. +enum CoachPrompts { + static let suggestions: [String] = [ + String(localized: "How's my charge trending?"), + String(localized: "What should today's training look like?"), + String(localized: "Analyse my sleep"), + String(localized: "Why am I run down?"), + ] +} diff --git a/Strand/Screens/CoachView.swift b/Strand/Screens/CoachView.swift index e84e109347..812b2048a3 100644 --- a/Strand/Screens/CoachView.swift +++ b/Strand/Screens/CoachView.swift @@ -14,9 +14,15 @@ import StrandDesign /// `sending`, `errorText`, `setKey(_:)`, `clearKey()`, and `send(_:)`. struct CoachView: View { @EnvironmentObject var coach: AICoachEngine + /// K8: used by "Save to Journal" — saves the coach advice as a journal entry with the text + /// in the notes field, so it appears alongside other journal entries in Insights. + @EnvironmentObject var repo: Repository /// Draft text in the composer (the question being typed). - @State private var draft: String = "" + /// K15: the composer draft is persisted to UserDefaults so it survives an app relaunch. + /// Restored on first appear, saved on every change. Keyed identically to the Android twin. + private static let draftKey = "coach.composerDraft" + @State private var draft: String = UserDefaults.standard.string(forKey: "coach.composerDraft") ?? "" /// Pending key text in the setup card (never persisted here, handed to `setKey`). @State private var keyDraft: String = "" /// Whether the model selector is in free-text "Custom…" mode. @@ -31,15 +37,27 @@ struct CoachView: View { @State private var promptDraft: String = "" @FocusState private var composerFocused: Bool + // K5: scheduled morning-brief notification settings (CoachBriefScheduler). + @State private var briefEnabled: Bool = CoachBriefScheduler.isEnabled + @State private var briefMinutes: Int = CoachBriefScheduler.timeMinutes + @State private var briefGenerating = false + @State private var briefStatus: String? + /// K2: confirmation gate for the destructive "Clear conversation" toolbar action. + @State private var showClearConfirm = false + + // K4: on-device voice input for the composer (iOS only). macOS gets a no-op stub via + // `#if os(iOS)` guards — the shared file keeps compiling for both targets. + #if os(iOS) + @StateObject private var voiceInput = CoachVoiceInput() + #endif + /// Sentinel tag for the "Custom…" entry in the model Picker. private let customModelTag = "__custom__" - private let suggestions = [ - String(localized: "How's my charge trending?"), - String(localized: "What should today's training look like?"), - String(localized: "Analyse my sleep"), - String(localized: "Why am I run down?"), - ] + /// Contextual suggestion chips, derived from today's bands by `AICoachEngine.suggestions` + /// (→ `CoachSuggestions`). Falls back to a stable generic set when there is no data. Recomputed + /// on each body evaluation so a fresh sync immediately updates the chips. + private var suggestions: [String] { coach.suggestions } var body: some View { ScreenScaffold(title: "Coach", @@ -54,13 +72,27 @@ struct CoachView: View { // v5: a SECOND opt-in, only meaningful once data access is on, folds a summary of the // new on-device signals (your strongest patterns + Lab Book) into the coach context. if coach.dataConsent { onDeviceSignalsBar } + if coach.dataConsent && coach.provider == .gemini { multimodalChartBar } systemPromptBar + morningBriefBar transcript if let error = coach.errorText, !error.isEmpty { errorBanner(error) } - suggestionChips + // K7: show follow-up chips after each assistant reply (when the transcript is + // non-empty and the last message is from the assistant and not mid-send); + // otherwise show the initial contextual chips. + if showFollowUpChips { + followUpChips + } else { + suggestionChips + } composer + // K12: show a rough token estimate when the draft is non-empty. + if !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let tokens = coach.estimatedTokens(forDraft: draft) { + tokenEstimateBar(tokens) + } privacyFootnote } else { setupCard @@ -68,6 +100,17 @@ struct CoachView: View { } .toolbar { if coach.isConfigured { + // K2: wipe the persisted + in-memory conversation. Confirmed, since it's destructive. + ToolbarItem { + Button(role: .destructive) { + showClearConfirm = true + } label: { + Label("Clear conversation", systemImage: "trash") + } + .help("Clear the saved conversation") + .accessibilityLabel("Clear conversation") + .disabled(coach.messages.isEmpty) + } ToolbarItem { Button(role: .destructive) { coach.disconnect() @@ -80,7 +123,142 @@ struct CoachView: View { } } } - .task(id: coach.dataConsent) { await coach.startBriefIfNeeded() } + .confirmationDialog( + "Clear conversation?", + isPresented: $showClearConfirm, + titleVisibility: .visible + ) { + Button("Clear", role: .destructive) { coach.clearConversation() } + Button("Cancel", role: .cancel) {} + } message: { + Text("This deletes the saved conversation from this device. Coach history is your own notes, not medical advice.") + } + // K2 + K5 ordering matters and every step gates on an EMPTY transcript, so this is ONE `.task` + // running sequentially (separate `.task`s can interleave at their await points on the same + // actor): restore whatever the prior launch persisted, THEN surface a brief the scheduled + // notification already generated (if any), THEN the interactive first-open brief — so + // `startBriefIfNeeded` only ever runs over the network when BOTH of the above left the + // transcript genuinely empty. + .task { + await coach.loadPersistedMessagesIfNeeded() + if let stored = CoachBriefScheduler.consumeStoredBrief() { + coach.surfaceScheduledBrief(stored) + } + CoachBriefScheduler.activateIfEnabled { await coach.generateBrief() } + await coach.startBriefIfNeeded() + } + // #1862: a question handed over by the Today launcher sheet. Cleared BEFORE sending so a view + // rebuild mid-flight cannot send it twice, and gated on `isConfigured` so an unconfigured handoff + // (which the launcher does not produce, but a future caller might) degrades to showing setup + // rather than a failed request. + .task(id: coach.pendingPrompt) { + guard let prompt = coach.pendingPrompt, !prompt.isEmpty else { return } + coach.pendingPrompt = nil + guard coach.isConfigured else { return } + await coach.send(prompt) + } + // K15: persist the composer draft so it survives an app relaunch. + .onChangeCompat(of: draft) { newValue in + UserDefaults.standard.set(newValue, forKey: Self.draftKey) + } + // K14: haptic feedback when a reply arrives (sending goes true → false). + .onChangeCompat(of: coach.sending) { isSending in + if !isSending && !coach.messages.isEmpty { + triggerReplyHaptic() + } + } + // A consent toggle AFTER the initial load re-checks the brief (the original `.task(id:)` + // behaviour); the guard inside `startBriefIfNeeded` (messages.isEmpty) keeps this a no-op once + // a conversation exists. + .onChangeCompat(of: coach.dataConsent) { _ in + Task { await coach.startBriefIfNeeded() } + } + } + + /// K5: the scheduled morning-brief notification settings — enable toggle, time-of-day picker, and an + /// explicit "Generate now" button. Mirrors the `ScheduledDebugExport` settings row shape (TestCentreView). + private var morningBriefBar: some View { + NoopCard(padding: 14, tint: StrandPalette.chargeColor) { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + Image(systemName: briefEnabled ? "sunrise.fill" : "sunrise") + .foregroundStyle(briefEnabled ? StrandPalette.accent : StrandPalette.textTertiary) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 1) { + Text("Morning brief").font(StrandFont.subhead).foregroundStyle(StrandPalette.textPrimary) + Text(briefEnabled + ? "A local notification with today's readiness + training plan, generated on-device each morning." + : "Off: nothing is generated or sent on a schedule.") + .font(StrandFont.footnote).foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 8) + Toggle("", isOn: $briefEnabled) + .labelsHidden().toggleStyle(.switch).tint(StrandPalette.accent) + .accessibilityLabel("Morning brief") + } + .onChangeCompat(of: briefEnabled) { on in + CoachBriefScheduler.setEnabled(on, generateBrief: { await coach.generateBrief() }) { outcome in + if outcome == .denied { + briefEnabled = false + briefStatus = "Notifications are off for NOOP — enable them in Settings first." + } + } + } + + if briefEnabled { + Divider().overlay(StrandPalette.hairline) + HStack { + Text("Time").font(StrandFont.subhead).foregroundStyle(StrandPalette.textPrimary) + Spacer() + DatePicker("", selection: briefTimeBinding, displayedComponents: .hourAndMinute) + .labelsHidden() + .accessibilityLabel("Morning brief time") + } + Text("At \(Platform.deviceNounPhrase == "Mac" ? "this time" : "or soon after"), NOOP will use your key to generate today's brief. Best-effort: \(Platform.deviceNounPhrase) decides exactly when a backgrounded app wakes.") + .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + NoopButton(briefGenerating ? "Generating…" : "Generate now", systemImage: "sparkles", kind: .secondary) { + generateBriefNow() + } + .disabled(briefGenerating) + if let briefStatus { + Text(briefStatus).font(StrandFont.footnote).foregroundStyle(StrandPalette.textTertiary) + } + } + } + } + } + + private var briefTimeBinding: Binding { + Binding( + get: { + var c = DateComponents() + c.hour = briefMinutes / 60 + c.minute = briefMinutes % 60 + return Calendar.current.date(from: c) ?? Date() + }, + set: { date in + let c = Calendar.current.dateComponents([.hour, .minute], from: date) + let m = (c.hour ?? 7) * 60 + (c.minute ?? 0) + briefMinutes = m + CoachBriefScheduler.setTimeMinutes(m, generateBrief: { await coach.generateBrief() }) + } + ) + } + + private func generateBriefNow() { + Task { + briefGenerating = true + briefStatus = nil + defer { briefGenerating = false } + let text = await CoachBriefScheduler.generateNow { await coach.generateBrief() } + if let text { + coach.appendGeneratedBrief(text) + } else { + briefStatus = "Couldn't generate a brief right now — check your key and data access." + } + } } /// Explicit, revocable permission for the coach to read & send the user's data. Off by default. @@ -133,6 +311,31 @@ struct CoachView: View { } } + /// K11: Third opt-in — send a chart image alongside the text when using Gemini's multimodal + /// API. Only shown when the provider is Gemini. OFF by default. + private var multimodalChartBar: some View { + NoopCard(padding: 14, tint: StrandPalette.chargeColor) { + HStack(spacing: 10) { + Image(systemName: coach.multimodalChartEnabled ? "photo.badge.checkmark" : "photo") + .foregroundStyle(coach.multimodalChartEnabled ? StrandPalette.accent : StrandPalette.textTertiary) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 1) { + Text("Send chart image to Gemini") + .font(StrandFont.subhead).foregroundStyle(StrandPalette.textPrimary) + Text(coach.multimodalChartEnabled + ? "On: a chart snapshot of your trends is sent with each question. Gemini can analyze the visual." + : "Off: only text is sent. Enable to let Gemini see your charts.") + .font(StrandFont.footnote).foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 8) + Toggle("", isOn: $coach.multimodalChartEnabled) + .labelsHidden().toggleStyle(.switch).tint(StrandPalette.accent) + .accessibilityLabel("Send chart image to Gemini") + } + } + } + /// Editable system prompt, the instructions that frame the coach. Collapsed by default; expanding /// reveals a TextEditor bound to the engine (edits persist to UserDefaults and take effect on the /// next message) plus a Reset-to-default control. Lives inline in the existing settings, NOT a modal. @@ -478,6 +681,7 @@ struct CoachView: View { // LLM replies arrive as Markdown (bold, lists, headings, tables), // rendered with the chat-bubble-sized Strand theme. User bubbles stay // verbatim `Text` so typed `*`/`#` never turn into surprise formatting. // The reply sits on a frosted Charge-tinted surface, a card, not a flat box. + // K8: context menu (long-press / right-click) with Copy, Share, and Save actions. HStack { Markdown(message.text) .markdownTheme(.strand) @@ -487,6 +691,27 @@ struct CoachView: View { .padding(.vertical, 11) .frostedCardSurface(tint: StrandPalette.chargeColor, cornerRadius: 16) .frame(maxWidth: 560, alignment: .leading) + // K8: Copy / Share / Save context menu on assistant replies. + .contextMenu { + Button { + #if os(macOS) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(message.text, forType: .string) + #else + UIPasteboard.general.string = message.text + #endif + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + ShareLink(item: message.text) { + Label("Share", systemImage: "square.and.arrow.up") + } + Button { + saveAdvice(message.text) + } label: { + Label("Save to Journal", systemImage: "square.and.pencil") + } + } Spacer(minLength: 48) } .accessibilityElement(children: .combine) @@ -552,6 +777,59 @@ struct CoachView: View { } } + /// K7: True when follow-up chips should show instead of the initial contextual chips — + /// i.e. the transcript is non-empty, the last message is from the assistant, and a reply + /// is not currently in flight. + private var showFollowUpChips: Bool { + guard let last = coach.messages.last, !coach.sending else { return false } + return last.role == .assistant + } + + /// K7: Follow-up suggestion chips shown after each assistant reply, so the user can dig + /// deeper without typing. Uses the static `AICoachEngine.followUpSuggestions` list. + private var followUpChips: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(AICoachEngine.followUpSuggestions, id: \.self) { prompt in + Button { + send(prompt) + } label: { + Text(prompt) + .font(StrandFont.captionNumber) + .foregroundStyle(StrandPalette.textSecondary) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(StrandPalette.surfaceInset, in: Capsule(style: .continuous)) + .overlay(Capsule(style: .continuous).strokeBorder(StrandPalette.hairline, lineWidth: 1)) + } + .buttonStyle(LiquidPressStyle()) + .disabled(coach.sending) + .accessibilityLabel("Follow-up prompt: \(prompt)") + } + } + .padding(.vertical, 1) + } + } + + /// K12: A subtle token estimate shown below the composer when the draft is non-empty. + /// Uses the ~4 chars/token heuristic — an estimate only, not an exact tokenizer count. + private func tokenEstimateBar(_ tokens: Int) -> some View { + HStack(spacing: 4) { + Image(systemName: "speedometer") + .font(.system(size: 10)) + .foregroundStyle(StrandPalette.textTertiary) + Text("~\(tokens) tokens") + .font(StrandFont.captionNumber) + .foregroundStyle(StrandPalette.textTertiary) + if tokens > 8000 { + Text("· may exceed small context windows") + .font(StrandFont.captionNumber) + .foregroundStyle(StrandPalette.textTertiary) + } + } + .padding(.top, 2) + } + /// The input bar, a frosted overlay surface holding the field + Send, so the composer reads as a /// distinct docked surface above the canvas rather than two floating controls. private var composer: some View { @@ -570,6 +848,11 @@ struct CoachView: View { .onSubmit { send(draft) } .accessibilityLabel("Question") + // K4: on-device voice input (iOS only). macOS compiles this section out entirely. + #if os(iOS) + micButton + #endif + // Docked icon-only send affordance: a crisp accent-filled square sized to the // composer row (not the full 48pt control height), so it routes through the same // token fill/label colours as the button system without overpowering the field. @@ -599,6 +882,82 @@ struct CoachView: View { .strokeBorder(StrandPalette.hairline, lineWidth: 1)) } + // MARK: - K4: Voice input (iOS only) + + #if os(iOS) + /// Mic button: starts/stops on-device speech recognition. Disabled when the locale lacks + /// on-device support or permission is denied; tapping when permission is not yet determined + /// triggers the system prompt. + private var micButton: some View { + Button { + toggleVoice() + } label: { + Group { + if voiceInput.isRecording { + Image(systemName: "stop.circle.fill") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(StrandPalette.statusCritical) + } else { + Image(systemName: "mic.fill") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(canUseVoice ? StrandPalette.textSecondary : StrandPalette.textTertiary) + } + } + .frame(width: 36, height: 38) + .background(StrandPalette.surfaceInset, + in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(StrandPalette.hairline, lineWidth: 1)) + } + .buttonStyle(.plain) + .disabled(!micButtonEnabled) + .help(voiceInput.statusMessage ?? "Ask out loud") + .accessibilityLabel(voiceInput.isRecording ? "Stop voice input" : "Voice input") + .accessibilityHint(voiceInput.statusMessage ?? "Transcribes your question on-device") + .task { + // Pre-check on appear so the button reflects the right state without a tap. + if voiceInput.authorization == .notDetermined { + voiceInput.requestAuthorization { _ in } + } + } + } + + /// Whether the mic button is tappable: not while sending, and only if voice is either + /// already usable or permission hasn't been asked yet (first tap triggers the prompt). + private var canUseVoice: Bool { voiceInput.canUseVoice } + private var micButtonEnabled: Bool { + !coach.sending && (canUseVoice || voiceInput.authorization == .notDetermined) + } + + private func toggleVoice() { + if voiceInput.isRecording { + voiceInput.stopTranscribing { finalText in + let trimmed = finalText.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + // Append to the draft (not replace) so a user can speak into existing text. + draft = draft.isEmpty ? trimmed : "\(draft) \(trimmed)" + } + } + } else { + // First tap with undetermined permission triggers the system prompt; if granted, + // start transcribing immediately on the next tap. If already authorized, start now. + if voiceInput.authorization == .notDetermined { + voiceInput.requestAuthorization { state in + if state == .authorized { + voiceInput.startTranscribing { partial in + draft = partial + } + } + } + } else { + voiceInput.startTranscribing { partial in + draft = partial + } + } + } + } + #endif + private var privacyFootnote: some View { Label { Text(coach.provider == .custom @@ -641,6 +1000,30 @@ struct CoachView: View { Task { await coach.send(trimmed) } } + /// K14: Trigger a subtle haptic when the Coach reply arrives. On iOS, a light impact feedback. + /// macOS doesn't have an equivalent simple API, so it's a no-op there. + private func triggerReplyHaptic() { + #if os(iOS) + let generator = UIImpactFeedbackGenerator(style: .light) + generator.impactOccurred() + #endif + } + + /// K8: Save a coach reply to the journal as a note, so it appears alongside other journal + /// entries in Insights and can be reviewed later. Uses the existing journal API with a + /// fixed question ("Coach advice") and the reply text in the notes field. + private func saveAdvice(_ text: String) { + let day = Repository.localDayKey(Date()) + Task { + await repo.saveJournalAnswer( + day: day, + question: "Coach advice", + answeredYes: true, + notes: text + ) + } + } + private func scrollToEnd(_ proxy: ScrollViewProxy) { withAnimation(StrandMotion.fade) { if coach.sending { diff --git a/Strand/Screens/ConsistencyCard.swift b/Strand/Screens/ConsistencyCard.swift index ee77b2f7c8..d4f1e576e4 100644 --- a/Strand/Screens/ConsistencyCard.swift +++ b/Strand/Screens/ConsistencyCard.swift @@ -31,7 +31,8 @@ struct ConsistencyCard: View { StatTile( label: "Consistency", value: pctValue(cons.latest), - caption: vsTypical(cons.latest, cons.typical, suffix: "%"), + caption: tileCaption(latestDay: cons.latestDay, latest: cons.latest, + typical: cons.typical, suffix: "%"), accent: cons.latest.map { StrandPalette.recoveryColor($0) } ?? StrandPalette.textPrimary, sparkline: spark(cons.series), sparkColor: StrandPalette.metricCyan) @@ -45,6 +46,15 @@ struct ConsistencyCard: View { v.map { "\(Int($0.rounded()))%" } ?? "—" } + /// #1946: a carried prior-day value is stamped "Carried · " instead of "vs typical". + private func tileCaption(latestDay: String?, latest: Double?, typical: Double?, + suffix: String, decimals: Int = 0) -> String { + if let carried = SleepModel.carriedMetricCaption(latestDay: latestDay, latest: latest) { + return carried + } + return vsTypical(latest, typical, suffix: suffix, decimals: decimals) + } + /// "+12% vs typical" — the latest-vs-mean caption the metric tile carries. private func vsTypical(_ latest: Double?, _ typical: Double?, suffix: String, decimals: Int = 0) -> String { guard let latest, let typical, typical != 0 else { return String(localized: "vs typical - ") } diff --git a/Strand/Screens/CoupledView.swift b/Strand/Screens/CoupledView.swift index 317483417c..a5a0b2fe3b 100644 --- a/Strand/Screens/CoupledView.swift +++ b/Strand/Screens/CoupledView.swift @@ -46,9 +46,7 @@ struct CoupledView: View { /// the cold-start threshold, which keeps the broad overnight-band bonus. @State private var habitualMidsleepSec: Int? = nil - /// #614: TODAY's workout count from the SAME deduped/dismissed-filtered list the Workouts screen shows - /// (`repo.workoutRows`), not `DailyMetric.exerciseCount` (strap-DETECTED only — a Health-Connect import - /// with auto-detect off read as 0 while the Workouts screen showed it). Loaded in `.task`. + /// Today's all-source workout count materialized onto the same physiological cycle as the other cards. @State private var workoutsToday: Int = 0 /// The day the coupled read describes, today's resolved row (the same `resolveToday` #304/#144 boundary @@ -159,14 +157,7 @@ struct CoupledView: View { // bed→wake span below resolves identically (#294). Re-runs on a sync/import refresh. .task(id: repo.refreshSeq) { habitualMidsleepSec = await repo.habitualMidsleepSec() - // #614: count TODAY's workouts by their START's logical day (the SAME 04:00-rollover key - // DailyMetric.day uses), so the Coupled stat equals the Workouts overview for today. - let key = todayKey - // A short window is enough to count today's rows (the cross-source dedup is local to the set), - // and avoids loading the full history just for one number. - workoutsToday = await repo.workoutRows(days: 2).filter { - Repository.logicalDayKey(Date(timeIntervalSince1970: TimeInterval($0.startTs))) == key - }.count + workoutsToday = day?.exerciseCount ?? 0 } } @@ -339,7 +330,7 @@ struct CoupledView: View { caloriesText, tint: StrandPalette.metricAmber) heroStat(String(localized: "Workouts"), - "\(workoutsToday)", // #614: real workout count (all sources), not exerciseCount + "\(workoutsToday)", tint: StrandPalette.textPrimary) } .frame(maxWidth: .infinity, alignment: .leading) @@ -705,12 +696,9 @@ struct CoupledView: View { Self.clockFmt.string(from: Date(timeIntervalSince1970: TimeInterval(ts))) } - private static let clockFmt: DateFormatter = { - let f = DateFormatter() - f.locale = AppLanguage.activeLocale - f.setLocalizedDateFormatFromTemplate("jmm") - return f - }() + /// #1821: routed through AppClock so the Clock format setting reaches this label. Was a `static + /// let`, which would have frozen the reader's choice at first use until the app relaunched. + private static var clockFmt: DateFormatter { AppClock.hourMinuteFormatter() } /// "6h 42m" from a minutes count, for the slept-vs-needed read. Mirror EXACTLY in Kotlin. static func hoursMinutes(_ minutes: Double) -> String { diff --git a/Strand/Screens/DashboardCards.swift b/Strand/Screens/DashboardCards.swift index 34a7430485..eef7805467 100644 --- a/Strand/Screens/DashboardCards.swift +++ b/Strand/Screens/DashboardCards.swift @@ -37,6 +37,13 @@ enum DashboardCard: String, CaseIterable, Identifiable { /// adds it via CUSTOMISE, matching the manual-first / default-OFF posture. case coupled + /// Optional, default-OFF (#1862): a tap-through that opens the Coach launcher SHEET rather than pushing + /// a screen — the one card that does. Coach is otherwise buried in More, and entering it means leaving + /// Today. Like `coupled` it carries no metric value of its own and is absent from `defaultSelection`, so + /// a fresh install never shows it: someone who does not use a provider should not gain a fixed dashboard + /// row for one. Opening the sheet makes NO provider request — see `CoachLauncherSheet`. + case coach + var id: String { rawValue } /// The card's display label (the UPPERCASE WHOOP metric-row label is derived from this). Localized via @@ -58,6 +65,7 @@ enum DashboardCard: String, CaseIterable, Identifiable { case .calories: return String(localized: "Calories") case .hydration: return String(localized: "Hydration") case .coupled: return String(localized: "Coupled view") + case .coach: return String(localized: "Coach") } } @@ -79,6 +87,9 @@ enum DashboardCard: String, CaseIterable, Identifiable { case .calories: return String(localized: "Active energy") case .hydration: return String(localized: "Today's fluid") case .coupled: return String(localized: "Recovery, strain and sleep in one glance") + // Reuses the Coach screen's own subtitle, so the card and the screen describe the feature + // identically and no new copy needs translating into ten locales. + case .coach: return String(localized: "Ask about your charge, effort, rest and workouts, grounded in your own numbers.") } } @@ -99,6 +110,7 @@ enum DashboardCard: String, CaseIterable, Identifiable { case .calories: return "flame.fill" case .hydration: return "waterbottle.fill" case .coupled: return "circle.hexagongrid.fill" + case .coach: return "bubble.left.and.text.bubble.right.fill" } } @@ -119,6 +131,7 @@ enum DashboardCard: String, CaseIterable, Identifiable { case .calories: return "kcal" case .hydration: return "" // value bakes in " / L" itself case .coupled: return "" // a tap-through row, no value, so no unit + case .coach: return "" // likewise: a launcher row, no metric of its own } } diff --git a/Strand/Screens/DevicesView.swift b/Strand/Screens/DevicesView.swift index d00f23aab2..5c5d57346d 100644 --- a/Strand/Screens/DevicesView.swift +++ b/Strand/Screens/DevicesView.swift @@ -91,6 +91,13 @@ private struct DevicesContent: View { /// has produced any clock signal - a routed frame, a clock correlation, or a data-range reply - so a /// generic HR strap or an idle card never shows a fabricated "waiting" state. One computation for /// both the line and the warning (the log scan is the cost worth paying once, not twice). + /// + /// #1818: the battery term reads `batterySamples.last`, NOT `batteryPct`. `batteryPct` deliberately + /// outlives the link (`clearBiometrics` leaves it) so Today/the widget can show a last-known charge, + /// and a 21 h old reading is indistinguishable from a fresh one there (#530). Withdrawing the "charge + /// it" advice on a stale 100% would suppress it in exactly the case it is right - a strap that ran + /// flat, which is what reset the RTC, reconnecting before its first battery event. `batterySamples` + /// is cleared on disconnect, so `.last` is a reading from THIS link or nothing. private var strapClockState: (line: String, warning: String?)? { guard live.connected else { return nil } let deviceClock = ConnectionReadout.clockCorrelatedDevice(logLines: live.log) @@ -100,7 +107,8 @@ private struct DevicesContent: View { let frame = ConnectionReadout.lastFrameLabel(lastFrameUnix: live.lastFrameAtUnix, nowUnix: Int(Date().timeIntervalSince1970)) let warning = ConnectionReadout.rtcWarning(deviceClockUnix: deviceClock, - strapNewestUnix: live.strapRange?.newestUnix) + strapNewestUnix: live.strapRange?.newestUnix, + batteryPct: live.batterySamples.last?.soc) return (String(localized: "Clock latched: \(latched) · last frame \(frame)"), warning) } diff --git a/Strand/Screens/FusedRecordView.swift b/Strand/Screens/FusedRecordView.swift index 1266ddcb07..487ea354ed 100644 --- a/Strand/Screens/FusedRecordView.swift +++ b/Strand/Screens/FusedRecordView.swift @@ -202,6 +202,15 @@ private struct FusedMetricRowView: View { let showProvenance: Bool let onCompare: () -> Void + // Each screen resolves °C/°F for itself (TodayView, FullDayChartView, MetricExplorerView do the + // same); the fused row used to print a hardcoded "°C" and was the last reader here ignoring it. + @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue + @AppStorage(UnitPrefs.temperatureKey) private var temperatureRaw = "" + private var temperatureUnit: TemperatureUnit { + UnitPrefs.resolveTemperature(system: UnitSystem(rawValue: unitSystemRaw) ?? .metric, + override: temperatureRaw) + } + private var point: FusedMetricPoint { row.point } private var accent: Color { @@ -217,7 +226,7 @@ private struct FusedMetricRowView: View { .font(StrandFont.headline) .foregroundStyle(StrandPalette.textPrimary) Spacer(minLength: 8) - Text(FusionFormat.value(point.value, metricKey: point.metric)) + Text(FusionFormat.value(point.value, metricKey: point.metric, temperature: temperatureUnit)) .font(StrandFont.number(20)) .foregroundStyle(accent) .lineLimit(1) @@ -268,7 +277,7 @@ private struct FusedMetricRowView: View { case .agree: if let other = point.contributors.dropFirst().first { - Text("\(other.source.displayName) agrees: \(FusionFormat.value(other.value, metricKey: point.metric))") + Text("\(other.source.displayName) agrees: \(FusionFormat.value(other.value, metricKey: point.metric, temperature: temperatureUnit))") .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) } @@ -277,7 +286,7 @@ private struct FusedMetricRowView: View { if let other = point.contributors.dropFirst().first { HStack(spacing: 6) { StatePill("Differs slightly", tone: .neutral, showsDot: false) - Text("\(other.source.displayName): \(FusionFormat.value(other.value, metricKey: point.metric))") + Text("\(other.source.displayName): \(FusionFormat.value(other.value, metricKey: point.metric, temperature: temperatureUnit))") .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textSecondary) Spacer(minLength: 0) @@ -306,7 +315,7 @@ private struct FusedMetricRowView: View { /// "Apple Health says 6h 40m. Tap to compare" style line for a conflict. private var conflictSummary: String { guard let other = point.contributors.dropFirst().first else { return String(localized: "Tap to compare") } - return String(localized: "\(other.source.displayName) says \(FusionFormat.value(other.value, metricKey: point.metric)). Tap to compare") + return String(localized: "\(other.source.displayName) says \(FusionFormat.value(other.value, metricKey: point.metric, temperature: temperatureUnit)). Tap to compare") } } @@ -369,6 +378,15 @@ private struct ContributorRow: View { let metricKey: String let isWinner: Bool + // Each screen resolves °C/°F for itself (TodayView, FullDayChartView, MetricExplorerView do the + // same); the fused row used to print a hardcoded "°C" and was the last reader here ignoring it. + @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue + @AppStorage(UnitPrefs.temperatureKey) private var temperatureRaw = "" + private var temperatureUnit: TemperatureUnit { + UnitPrefs.resolveTemperature(system: UnitSystem(rawValue: unitSystemRaw) ?? .metric, + override: temperatureRaw) + } + var body: some View { HStack(alignment: .center, spacing: 12) { VStack(alignment: .leading, spacing: 4) { @@ -384,7 +402,7 @@ private struct ContributorRow: View { .foregroundStyle(StrandPalette.textTertiary) } Spacer(minLength: 8) - Text(FusionFormat.value(contrib.value, metricKey: metricKey)) + Text(FusionFormat.value(contrib.value, metricKey: metricKey, temperature: temperatureUnit)) .font(StrandFont.number(18)) .foregroundStyle(isWinner ? StrandPalette.textPrimary : StrandPalette.textSecondary) .lineLimit(1) @@ -394,8 +412,8 @@ private struct ContributorRow: View { .accessibilityElement(children: .combine) // Whole-string key per variant (never a concatenated localized tail on an a11y label). .accessibilityLabel(isWinner - ? "\(contrib.source.displayName), \(FusionFormat.value(contrib.value, metricKey: metricKey)), in use" - : "\(contrib.source.displayName), \(FusionFormat.value(contrib.value, metricKey: metricKey))") + ? "\(contrib.source.displayName), \(FusionFormat.value(contrib.value, metricKey: metricKey, temperature: temperatureUnit)), in use" + : "\(contrib.source.displayName), \(FusionFormat.value(contrib.value, metricKey: metricKey, temperature: temperatureUnit))") } } @@ -403,9 +421,10 @@ private struct ContributorRow: View { /// Formats a fused metric's `Double` value for display by its resolver key. Pure + local to this /// screen: the engine deals in numbers, the UI owns units. Sleep/duration keys read as "7h 12m"; -/// temp as "34.1°C"; HR/HRV/steps as plain integers with the right unit. +/// temp as "34.1 °C" / "93.4 °F" per the reader's preference; HR/HRV/steps as plain +/// integers with the right unit. enum FusionFormat { - static func value(_ v: Double, metricKey: String) -> String { + static func value(_ v: Double, metricKey: String, temperature: TemperatureUnit) -> String { switch MetricArbitrationPolicy.kind(forKey: metricKey) { case .restingHR, .heartRate: return "\(Int(v.rounded())) bpm" @@ -414,7 +433,12 @@ enum FusionFormat { case .spo2: return "\(Int(v.rounded()))%" case .skinTemp: - return String(format: "%.1f°C", v) + // #111/#622: this column is BIMODAL — CSV/Health imports carry an ABSOLUTE wrist °C (~30-35), + // the live BLE pipeline a signed DEVIATION from baseline. Fusion is the one screen that can + // show both at once, side by side, so the conversion cannot be assumed: +32 belongs only to + // the absolute reading, and applying it to a −4.2 deviation is what once printed "24.4 °F". + // SkinTempDisplay picks the conversion AND the chip ("°F" vs "Δ°F") from the value itself. + return SkinTempDisplay.format(v, fahrenheit: temperature == .fahrenheit) case .steps: return integerGrouped(v) case .sleep: diff --git a/Strand/Screens/HealthView.swift b/Strand/Screens/HealthView.swift index e9b30c1bbb..b168717dae 100644 --- a/Strand/Screens/HealthView.swift +++ b/Strand/Screens/HealthView.swift @@ -135,7 +135,10 @@ private struct SyncStatusSection: View { @EnvironmentObject var model: AppModel /// The strap link is usable for a manual offload kick (matches BLEManager.syncNow's own gate). - private var canSync: Bool { live.connected && live.bonded && !live.backfilling } + /// + /// `historyReady`, not `bonded` alone: the live-HR path sets `bonded` for a 5/MG that has never + /// completed a handshake, so this enabled itself and beginBackfill then declined it silently. + private var canSync: Bool { live.connected && live.bonded && live.historyReady && !live.backfilling } var body: some View { VStack(alignment: .leading, spacing: NoopMetrics.gap) { @@ -187,8 +190,10 @@ private struct SyncStatusSection: View { .foregroundStyle(StrandPalette.textSecondary) } } else { - StatePill(live.bonded ? "Ready to sync" : "Pairing…", - tone: .accent, showsDot: true, pulsing: !live.bonded) + // Same condition as the button below it. Keyed on `bonded` this said "Ready to sync" directly + // above a DISABLED Sync now, on exactly the strap that cannot sync. + StatePill(live.historyReady ? "Ready to sync" : "Pairing…", + tone: .accent, showsDot: true, pulsing: !live.historyReady) } } @@ -199,7 +204,11 @@ private struct SyncStatusSection: View { if !live.connected { return String(localized: "Connect your strap to sync its stored history. Until then, only imported data shows here.") } - if !live.bonded { + // historyReady, not `bonded`. This branch already said the right thing and simply never fired on + // the strap that needed it: `bonded` is set by the live-HR path, so a 5/MG that never completed a + // handshake fell through to the "syncs right away" line, under a Sync-now button that had just + // been disabled. Same condition as the button and the pill, so all three agree. + if !live.historyReady { return String(localized: "Finishing the pairing handshake. Sync now becomes available once the strap is paired.") } return String(localized: "Syncs your strap's stored history right away, instead of waiting for the next automatic sync.") @@ -1227,6 +1236,7 @@ private struct VitalsSection: View { // Temperature display preference (D#103). Skin temp is stored in °C (absolute or a ±deviation); the // toggle re-labels it to °F. Display-only — banding still runs on the stored °C value. @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue + @AppStorage(UnitPrefs.skinTempDisplayKey) private var skinTempDisplayRaw = "" // #1846 @AppStorage(UnitPrefs.temperatureKey) private var temperatureRaw = "" private var temperatureUnit: TemperatureUnit { let system = UnitSystem(rawValue: unitSystemRaw) ?? .metric @@ -1244,7 +1254,8 @@ private struct VitalsSection: View { sourceRows: repo.vitalMetricRows, temperatureUnit: temperatureUnit, spo2CandidateByDay: spo2CandidateByDay, - hrvOverCountByDay: hrvOverCountByDay + hrvOverCountByDay: hrvOverCountByDay, + skinTempPreferred: SkinTempDisplay.Kind(rawValue: skinTempDisplayRaw) ?? .absolute // #1846 ) VStack(alignment: .leading, spacing: NoopMetrics.gap) { SectionHeader("Vital Signs", overline: "Latest", trailing: BodyVitalSigns.latestDayLabel(readings)) diff --git a/Strand/Screens/HoursVsNeededCard.swift b/Strand/Screens/HoursVsNeededCard.swift index 63feec0fca..2dfb638b90 100644 --- a/Strand/Screens/HoursVsNeededCard.swift +++ b/Strand/Screens/HoursVsNeededCard.swift @@ -30,7 +30,8 @@ struct HoursVsNeededCard: View { StatTile( label: "Hours vs Needed", value: pctValue(need.latest), - caption: vsTypical(need.latest, need.typical, suffix: "%"), + caption: tileCaption(latestDay: need.latestDay, latest: need.latest, + typical: need.typical, suffix: "%"), accent: need.latest.map { StrandPalette.recoveryColor(min(100, $0)) } ?? StrandPalette.textPrimary, sparkline: spark(need.series), sparkColor: StrandPalette.restColor) @@ -44,6 +45,15 @@ struct HoursVsNeededCard: View { v.map { "\(Int($0.rounded()))%" } ?? "—" } + /// #1946: a carried prior-day value is stamped "Carried · " instead of "vs typical". + private func tileCaption(latestDay: String?, latest: Double?, typical: Double?, + suffix: String, decimals: Int = 0) -> String { + if let carried = SleepModel.carriedMetricCaption(latestDay: latestDay, latest: latest) { + return carried + } + return vsTypical(latest, typical, suffix: suffix, decimals: decimals) + } + /// "+12% vs typical" — the latest-vs-mean caption the metric tile carries. private func vsTypical(_ latest: Double?, _ typical: Double?, suffix: String, decimals: Int = 0) -> String { guard let latest, let typical, typical != 0 else { return String(localized: "vs typical - ") } diff --git a/Strand/Screens/HydrationView.swift b/Strand/Screens/HydrationView.swift index d5f865ec10..dc67d012df 100644 --- a/Strand/Screens/HydrationView.swift +++ b/Strand/Screens/HydrationView.swift @@ -274,9 +274,9 @@ struct HydrationView: View { } } - private static let entryTimeFmt: DateFormatter = { - let f = DateFormatter(); f.timeStyle = .short; f.dateStyle = .none; return f - }() + /// #1821: routed through AppClock so the Clock format setting reaches this label. Was a `static + /// let`, which would have frozen the reader's choice at first use until the app relaunched. + private static var entryTimeFmt: DateFormatter { AppClock.hourMinuteFormatter() } // MARK: - 7-day mini history (flat bars, today on the right) diff --git a/Strand/Screens/InsightsHubView.swift b/Strand/Screens/InsightsHubView.swift index 9bfca37e78..a5fe185a22 100644 --- a/Strand/Screens/InsightsHubView.swift +++ b/Strand/Screens/InsightsHubView.swift @@ -546,6 +546,8 @@ final class InsightsHubViewModel: ObservableObject { // MARK: Loaded inputs (kept so the outcome segmented control can re-rank cheaply) private var behaviours: [String: Set] = [:] + /// Per behaviour, the days it was logged NO — the only legitimate control group. + private var controls: [String: Set] = [:] private var outcomeByKey: [String: [String: Double]] = [:] private var currentOutcome: Outcome = .recovery @@ -559,9 +561,13 @@ final class InsightsHubViewModel: ObservableObject { func load(repo: Repository) async { // Journal → behaviour → days (only "yes" answers count as the behaviour occurring). let entries = await repo.journalEntries() + // Yes days and NO days, kept apart. A day with no journal row for the question lands in + // neither, so an unanswered day is never counted as a No (BehaviorInsights.effect). var byBehaviour: [String: Set] = [:] - for e in entries where e.answeredYes { - byBehaviour[e.question, default: []].insert(e.day) + var controlsByBehaviour: [String: Set] = [:] + for e in entries { + if e.answeredYes { byBehaviour[e.question, default: []].insert(e.day) } + else { controlsByBehaviour[e.question, default: []].insert(e.day) } } // Outcome series: imported metricSeries ∪ the DailyMetric column fallback so an @@ -611,6 +617,7 @@ final class InsightsHubViewModel: ObservableObject { } self.behaviours = byBehaviour + self.controls = controlsByBehaviour self.outcomeByKey = byKey self.doseCards = cards self.loaded = true @@ -622,6 +629,7 @@ final class InsightsHubViewModel: ObservableObject { currentOutcome = outcome let outcomeDays = outcomeByKey[outcome.key] ?? [:] ranked = EffectRanker.rank(behaviors: behaviours, + controls: controls, outcomeByDay: outcomeDays, outcome: outcome.outcomeName) } diff --git a/Strand/Screens/InsightsView.swift b/Strand/Screens/InsightsView.swift index af2c34b2d6..7d455bdcce 100644 --- a/Strand/Screens/InsightsView.swift +++ b/Strand/Screens/InsightsView.swift @@ -40,6 +40,9 @@ private struct InsightsLoadKey: Equatable { /// the seq AND the dayKey still match (see `Repository.insightsLoadedSeq` / `insightsLoadedDayKey`). struct InsightsLoadCache { let behaviours: [String: Set] + /// Per behaviour, the days it was logged NO. Cached alongside `behaviours` because a restore that + /// dropped it would leave the ranker with no controls and silently produce no insights at all. + let controls: [String: Set] let importedQuestions: [String] let dayAnswers: [String: Bool] /// The journal day offset the `dayAnswers` were read for (0 = today, 1 = yesterday, -1 = tomorrow). The @@ -152,6 +155,8 @@ struct InsightsView: View { /// behaviour question → set of days where it was answered yes. @State private var behaviours: [String: Set] = [:] + /// Per behaviour, the days it was logged NO — the only legitimate control group. + @State private var controls: [String: Set] = [:] /// outcome key → [day: value]. @State private var outcomeByKey: [String: [String: Double]] = [:] /// outcome key → ordered (day, value) series for correlations. @@ -351,10 +356,14 @@ struct InsightsView: View { // log writes answeredYes=true too (#322), so a numeric item lands in the with/without split // here unchanged, on top of the numeric series read below. let entries = await repo.journalEntries() + // Yes days and NO days, kept apart. A day with no journal row for the question lands in + // neither, so an unanswered day is never counted as a No (BehaviorInsights.effect). var byBehaviour: [String: Set] = [:] + var controlsByBehaviour: [String: Set] = [:] var numericByBehaviour: [String: [String: Double]] = [:] - for e in entries where e.answeredYes { - byBehaviour[e.question, default: []].insert(e.day) + for e in entries { + if e.answeredYes { byBehaviour[e.question, default: []].insert(e.day) } + else { controlsByBehaviour[e.question, default: []].insert(e.day) } } // #322: per-question numeric series (question → [day: value]) for numeric journal items. A // numeric series is the same [day: value] shape a metric outcome is, so the effect ranker can @@ -415,6 +424,7 @@ struct InsightsView: View { await MainActor.run { self.behaviours = byBehaviour + self.controls = controlsByBehaviour self.importedQuestions = importedQs self.dayAnswers = nativeAnswers self.dayNumeric = nativeNumeric @@ -432,6 +442,7 @@ struct InsightsView: View { // freshest read, a subsequent re-mount never restores stale data behind a journal toggle. self.repo.insightsCache = InsightsLoadCache( behaviours: byBehaviour, + controls: controlsByBehaviour, importedQuestions: importedQs, dayAnswers: nativeAnswers, journalDayOffset: self.journalDayOffset, @@ -450,6 +461,7 @@ struct InsightsView: View { @MainActor private func restoreFromCache(_ c: InsightsLoadCache) { behaviours = c.behaviours + controls = c.controls importedQuestions = c.importedQuestions dayAnswers = c.dayAnswers // Numeric journal rows are native-only (imported WHOOP rows never carry a numericValue), so the @@ -516,6 +528,7 @@ struct InsightsView: View { let outcomeDays = outcomeByKey[outcome.key] ?? [:] ranked = BehaviorInsights.rank( behaviors: behaviours, + controls: controls, outcomeByDay: outcomeDays, outcome: outcome.outcomeName ) diff --git a/Strand/Screens/LiveView.swift b/Strand/Screens/LiveView.swift index aad7ca8e1f..d9dc0364df 100644 --- a/Strand/Screens/LiveView.swift +++ b/Strand/Screens/LiveView.swift @@ -361,28 +361,44 @@ struct LiveView: View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 8) { Circle().fill(StrandPalette.metricRose).frame(width: 8, height: 8) - Text("RECORDING WORKOUT").font(StrandFont.overline) - .tracking(StrandFont.overlineTracking).foregroundStyle(StrandPalette.metricRose) + // "RECORDING" is a factual claim, and while paused nothing IS being recorded — the + // sample capture drops every reading. So the label swaps rather than gaining a tag + // beside it, which would leave the card asserting both at once. Reuses the "Paused" + // string #1533 already localized. (The Today card says "IN PROGRESS", which stays + // true while paused, so it keeps its label and takes the tag instead.) + Text(w.isPaused ? "Paused" : "RECORDING WORKOUT").font(StrandFont.overline) + .tracking(StrandFont.overlineTracking) + .foregroundStyle(w.isPaused ? StrandPalette.textSecondary : StrandPalette.metricRose) Spacer() // Re-render once a second so the elapsed clock ticks without a manual Timer. - TimelineView(.periodic(from: .now, by: 1)) { _ in - Text(Self.elapsed(since: w.start)).font(StrandFont.number(17)).monospacedDigit() + TimelineView(.periodic(from: .now, by: 1)) { context in + Text(ActiveWorkoutClock.clock(Int(w.elapsed(at: context.date)))) + .font(StrandFont.number(17)).monospacedDigit() .foregroundStyle(StrandPalette.textPrimary) } } // Live HR / avg / peak / effort — the leaf owns LiveState + the active workout so the // 1 Hz stat refresh re-renders only these tiles, plus a liquid effort tube under them. ActiveWorkoutLive(workout: w, effortScale: effortScale) + // The card used to offer End and nothing else, so the only IRREVERSIBLE control was the + // one reachable without opening the live view, while Pause — the reversible one — was + // not. Pause/Resume is one toggle (a paused session has exactly one sensible action), and + // End moves to its own row so a destructive tap is not adjacent to a routine one. HStack(spacing: NoopMetrics.rowSpacing) { + NoopButton(w.isPaused ? "Resume" : "Pause", + systemImage: w.isPaused ? "play.fill" : "pause.fill", + kind: .secondary, fullWidth: true) { + model.toggleWorkoutPause() + } // Re-open the full live workout screen (#238) after it's been dismissed. NoopButton("Open live view", systemImage: "rectangle.expand.vertical", kind: .secondary, fullWidth: true) { showLiveWorkout = true } - NoopButton("End workout", systemImage: "stop.circle.fill", - kind: .destructive, fullWidth: true) { - confirmingEndWorkout = true - } + } + NoopButton("End workout", systemImage: "stop.circle.fill", + kind: .destructive, fullWidth: true) { + confirmingEndWorkout = true } } } @@ -401,11 +417,6 @@ struct LiveView: View { .padding(.horizontal, 4) } - private static func elapsed(since start: Date) -> String { - let s = max(0, Int(Date().timeIntervalSince(start))) - return String(format: "%d:%02d", s / 60, s % 60) - } - private func reconnectGuideBanner(_ guide: String) -> some View { HStack(alignment: .top, spacing: 10) { Image(systemName: "exclamationmark.triangle.fill") diff --git a/Strand/Screens/LiveWorkoutView.swift b/Strand/Screens/LiveWorkoutView.swift index 1fa83dd943..f1a8b06799 100644 --- a/Strand/Screens/LiveWorkoutView.swift +++ b/Strand/Screens/LiveWorkoutView.swift @@ -318,27 +318,59 @@ struct LiveWorkoutView: View { // MARK: - Bottom floating controls - /// Diameter shared by the exit and workout-type Liquid Glass circles so the centered timer stays - /// optically balanced against equal side chrome. + /// Diameter shared by every Liquid Glass circle in the bottom capsule, so the row reads as one + /// set of controls rather than a mix of sizes. + /// + /// It used to justify itself as keeping "the centered timer optically balanced against equal side + /// chrome". The side chrome has not been equal since #1533 put two circles on the left and one on + /// the right, and the timer is no longer centred against them — see `bottomControlRow`. private static let bottomControlDiameter: CGFloat = 56 /// Tight inset so the glass circles nest into the capsule ends (stopwatch-bar proportions). private static let bottomBarInset: CGFloat = 4 - /// One shared dark floating capsule: Liquid Glass exit · elapsed · Liquid Glass workout-type. - /// The timer is centered in the bar via ZStack; the glass circles sit in a separate HStack so - /// uneven label widths cannot pull the time off-center. The capsule itself is solid elevated - /// chrome — not Liquid Glass. + /// One shared dark floating capsule: discard · pause · elapsed · end. + /// + /// Laid out in ONE HStack, so the timer and the controls cannot overlap. #1068 built this as a + /// ZStack with the timer centred independently — deliberately, "so uneven label widths cannot pull + /// the time off-center" — and that held while the bar carried one circle per side. #1533 added the + /// discard and pause controls to the left group, and a centred 40pt timer then began where two + /// 56pt circles plus their spacing end: `0:02` merely touched the pause button, and anything wider + /// went under it. A field report of "two timers" was this one half-occluded, read as a duplicate of + /// the big TIME readout above. `.allowsHitTesting(false)` on the timer was already a tell that it + /// sat beneath something tappable. + /// + /// The trade is deliberate: the timer now sits centred in the space the buttons leave rather than + /// in the bar, so it reads slightly right of true centre because the left chrome is heavier. That + /// is the cost of the layout being unable to collide at all. The buttons keep the positions they + /// have shipped with — moving pause to the right would centre the timer better and would also move + /// a control under the thumb of everyone already using this screen, which is a worse trade than an + /// off-centre clock. + /// + /// It can still run out of ROOM, and the arithmetic is tighter than it looks: three 56pt circles + /// and their gaps leave the timer roughly 161pt on a 393pt screen, against about 144pt for a + /// `1:30:00` at 40pt monospaced. On a 375pt device, or at a larger Dynamic Type, that does not fit, + /// so the timer scales rather than overflowing the capsule. The alternative considered — padding + /// the timer clear of the widest group to keep true centring — left it barely 105pt and would have + /// truncated the same clock outright. private var bottomControlRow: some View { - ZStack { + HStack(spacing: NoopMetrics.space2) { + deleteWorkoutGlassButton + pauseWorkoutGlassButton + Spacer(minLength: NoopMetrics.space2) bottomElapsedTimer .allowsHitTesting(false) - - HStack(spacing: NoopMetrics.space2) { - deleteWorkoutGlassButton - pauseWorkoutGlassButton - Spacer(minLength: 0) - endWorkoutGlassButton - } + // Scaling down is the honest failure when the room runs out: truncating a clock to + // "1:30:0" would be worse than a smaller one. Same idiom the rest of this file uses. + .lineLimit(1) + .minimumScaleFactor(0.6) + // NO layoutPriority here, deliberately. Raising the timer's priority sizes it BEFORE + // the three circles, and those are fixed 56pt frames — inflexible, so when the space + // runs out they do not shrink, they clip. That inverts which element gives way: a + // half-drawn pause button is worse than a smaller clock, and a clipped control is the + // failure this whole change exists to remove. At equal priority the inflexible frames + // are satisfied first and the Text scales into what is left, which is the order wanted. + Spacer(minLength: NoopMetrics.space2) + endWorkoutGlassButton } .padding(Self.bottomBarInset) .background { @@ -423,9 +455,12 @@ struct LiveWorkoutView: View { // MARK: - Helpers + /// Delegates to the shared clock. This carried its own `%d:%02d` with NO hour roll-over, so a + /// 90-minute session read "90:00" here while Android's live workout screen read "1:30:00" — and, + /// after the card fix, while the iOS card that opens THIS screen read "1:30:00" too. The math was + /// already pause-aware (`workout.elapsed()`); only the formatting was the odd one out. private static func elapsed(seconds: TimeInterval) -> String { - let s = max(0, Int(seconds)) - return String(format: "%d:%02d", s / 60, s % 60) + ActiveWorkoutClock.clock(Int(seconds)) } private static func zoneName(_ zone: Int) -> String { @@ -463,8 +498,8 @@ private extension View { /// power meter) feeding RSC/CSC/CPS ALONGSIDE heart rate. Only the fields the sensor actually sent /// render — each metric is dropped when its value is absent, and the WHOLE block (panel + entrance stagger) /// is hidden when nothing is present (`live.hasSensorMetrics`), so a plain HR-only workout looks exactly -/// as before. Honest units: speed km/h, cadence per-minute (steps for running / rpm for cycling), power -/// watts. Tinted with the Effort world so it reads as part of the hero, not a competing accent. Nothing +/// as before. Speed follows the exercise-distance preference; cadence stays per-minute and power in watts. +/// Tinted with the Effort world so it reads as part of the hero, not a competing accent. Nothing /// here touches HR / zone / effort. /// /// This is a standalone leaf that owns its OWN `@EnvironmentObject live` (the parent `LiveWorkoutView` @@ -475,10 +510,18 @@ private extension View { /// so the rendered output matches the previous inline code. private struct SensorRowIfPresent: View { @EnvironmentObject private var live: LiveState + @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue + @AppStorage(UnitPrefs.distanceSystemKey) private var distanceSystemRaw = "" + private var distanceUnitSystem: UnitSystem { + UnitPrefs.resolveDistance( + system: UnitSystem(rawValue: unitSystemRaw) ?? .metric, + override: distanceSystemRaw) + } var body: some View { if live.hasSensorMetrics { - let speed = LiveState.formatSpeedKmh(live.sensorSpeedKmh) + let speed = UnitFormatter.speedFromKilometersPerHour( + live.sensorSpeedKmh, system: distanceUnitSystem) let cadence = LiveState.formatCadence(live.sensorCadence) let power = LiveState.formatPowerWatts(live.sensorPowerWatts) NoopCard(padding: NoopMetrics.cardInnerPadding, tint: StrandPalette.effortColor) { @@ -487,7 +530,7 @@ private struct SensorRowIfPresent: View { .font(StrandFont.overline).tracking(StrandFont.overlineTracking) .foregroundStyle(StrandPalette.textSecondary) HStack(spacing: NoopMetrics.gap) { - if let speed { stat(String(localized: "SPEED"), "\(speed) km/h", tint: StrandPalette.effortColor) } + if let speed { stat(String(localized: "SPEED"), speed, tint: StrandPalette.effortColor) } if let cadence { stat(String(localized: "CADENCE"), "\(cadence)/min", tint: StrandPalette.effortColor) } if let power { stat(String(localized: "POWER"), "\(power) W", tint: StrandPalette.effortColor) } } @@ -525,7 +568,12 @@ private struct SensorRowIfPresent: View { private struct DistancePaceRowIfPresent: View { @ObservedObject var recorder: GpsWorkoutRecorder @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue - private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } + @AppStorage(UnitPrefs.distanceSystemKey) private var distanceSystemRaw = "" + private var distanceUnitSystem: UnitSystem { + UnitPrefs.resolveDistance( + system: UnitSystem(rawValue: unitSystemRaw) ?? .metric, + override: distanceSystemRaw) + } var body: some View { // `isRecording` is essential, not just `pointCount > 0`: the recorder is a single long-lived @@ -539,10 +587,10 @@ private struct DistancePaceRowIfPresent: View { // "Distance"/"Pace" are already localized (reused from the detail view); uppercased for // the caps stat grid, exactly as the detail route stats do. stat(String(localized: "Distance").uppercased(), - UnitFormatter.distanceFromMeters(recorder.distanceM, system: unitSystem)) + UnitFormatter.distanceFromMeters(recorder.distanceM, system: distanceUnitSystem)) statDivider stat(String(localized: "Pace").uppercased(), - UnitFormatter.paceFromSecPerKm(recorder.paceSecPerKm, system: unitSystem)) + UnitFormatter.paceFromSecPerKm(recorder.paceSecPerKm, system: distanceUnitSystem)) } } } diff --git a/Strand/Screens/ManualWorkoutSheet.swift b/Strand/Screens/ManualWorkoutSheet.swift index a316ca9f0f..7011bedd45 100644 --- a/Strand/Screens/ManualWorkoutSheet.swift +++ b/Strand/Screens/ManualWorkoutSheet.swift @@ -37,12 +37,15 @@ struct ManualWorkoutSheet: View { /// Distance as ENTERED, in the user's unit (km or mi) — converted to stored metres on save (#1195). @State private var distanceText: String - /// The length unit system, so the Distance field reads/writes in the user's unit. Read live for the - /// field label + parse; the init pre-fill reads the same key straight from UserDefaults (@AppStorage - /// isn't available before `self` is formed). + /// Exercise-distance choice. Unset follows the original combined setting for existing installs. @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue - private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } - private var distanceUnit: String { unitSystem == .imperial ? "mi" : "km" } + @AppStorage(UnitPrefs.distanceSystemKey) private var distanceSystemRaw = "" + private var distanceUnitSystem: UnitSystem { + UnitPrefs.resolveDistance( + system: UnitSystem(rawValue: unitSystemRaw) ?? .metric, + override: distanceSystemRaw) + } + private var distanceUnit: String { UnitFormatter.distanceUnit(distanceUnitSystem) } /// Focus for the numeric (Avg HR / Calories / Distance) fields so the keyboard Done button can resign /// them — the decimal pad has no return key. iOS-only effect; the enum keeps both platforms compiling. @@ -74,7 +77,11 @@ struct ManualWorkoutSheet: View { // Pre-fill the distance in the user's unit so an untouched edit round-trips the stored metres // (buildManualRow then re-stores exactly what's shown). @AppStorage isn't usable pre-init, so read // the same key directly. - let sys = UnitSystem(rawValue: UserDefaults.standard.string(forKey: UnitPrefs.systemKey) ?? "") ?? .metric + let bodySystem = UnitSystem( + rawValue: UserDefaults.standard.string(forKey: UnitPrefs.systemKey) ?? "") ?? .metric + let sys = UnitPrefs.resolveDistance( + system: bodySystem, + override: UserDefaults.standard.string(forKey: UnitPrefs.distanceSystemKey) ?? "") _distanceText = State(initialValue: e?.distanceM.map { Self.distanceEntryString($0, system: sys) } ?? "") } @@ -383,7 +390,7 @@ struct ManualWorkoutSheet: View { private var distanceMeters: Double? { let t = distanceText.trimmingCharacters(in: .whitespaces) guard !t.isEmpty, let v = Double(t), v >= 0 else { return nil } - let km = unitSystem == .imperial ? v / UnitFormatter.milesPerKilometer : v + let km = distanceUnitSystem == .imperial ? v / UnitFormatter.milesPerKilometer : v return km * 1000.0 } @@ -425,7 +432,9 @@ struct ManualWorkoutSheet: View { } if !distanceText.trimmingCharacters(in: .whitespaces).isEmpty, distanceMeters == nil || (distanceMeters ?? -1) < 0 || (distanceMeters ?? 0) > 1_000_000 { - return String(localized: "Distance must be 0–1,000 km.") + return distanceUnitSystem == .imperial + ? String(localized: "Distance must be 0–621 mi.") + : String(localized: "Distance must be 0–1,000 km.") } return String(localized: "Check the values and try again.") } diff --git a/Strand/Screens/MetricExplorerView.swift b/Strand/Screens/MetricExplorerView.swift index fd71b0e2a7..887828a9db 100644 --- a/Strand/Screens/MetricExplorerView.swift +++ b/Strand/Screens/MetricExplorerView.swift @@ -182,6 +182,18 @@ func vo2MaxAttributionSource(_ estimator: Vo2MaxEstimator?) -> String { vo2MaxAttributionPrefix + (estimator?.rawValue ?? "unknown") } +/// Will the chart show a visible break in this VO₂max trend? +/// +/// Derived from `vo2MaxTrendSegmentIds` rather than recomputed, so the caption and the segmentation can +/// never disagree. A GAP IN DAYS under one estimator is still a single segment and draws no break, so it +/// correctly gets no caption: a break means the readings were not produced alike, not that the data +/// paused. Named for the BREAK: an untagged legacy reading resolves to "...estimator:unknown", so an +/// unknown -> Nes transition splits the line while the method itself may never have changed. +/// Kotlin twin `vo2MaxTrendHasBreak`. +func vo2MaxTrendHasBreak(days: [String], sourceByDay: [String: String]) -> Bool { + Set(vo2MaxTrendSegmentIds(days: days, sourceByDay: sourceByDay)).count > 1 +} + /// Sequential segment ids for the VO₂max trend. The counter matters when a user changes Nes → Uth → Nes: /// using the method name alone would reconnect the two non-adjacent Nes runs across the Uth interval. func vo2MaxTrendSegmentIds(days: [String], sourceByDay: [String: String]) -> [String] { @@ -253,6 +265,32 @@ private let readingShortDateFormatter: DateFormatter = { return f }() +// MARK: - Skin-temp explorer notes (#1847 / #1848) + +/// Whether the skin-temp explorer must explain that it fell back to deviations despite the +/// user's Settings choice asking for temperatures. Twin of Android's `shouldExplainSkinTempFallback`. +/// +/// True only when the user asked for absolute, the screen is NOT leading with absolutes, and NO +/// night in the window carries one — so the fallback is total, not partial. A window with one +/// stored temperature and twenty deltas still leads with temperatures (the #1850 window-wide rule), +/// so this note stays silent there; it fires only when the setting genuinely cannot be honoured. +func shouldExplainSkinTempFallback(prefer: SkinTempDisplay.Kind, leadsAbsolute: Bool, + anyAbsoluteInWindow: Bool) -> Bool { + prefer == .absolute && !leadsAbsolute && !anyAbsoluteInWindow +} + +/// Whether the skin-temp explorer must explain that deviation-only nights were dropped from the +/// series when leading with absolutes. Twin of Android's `shouldExplainShortenedSkinTempSeries`. +/// +/// True ONLY when leading with the absolute — the deviation-led branch also drops rows (calibrating +/// nights that have only an absolute, and the #622 bimodal partition), but those are the OPPOSITE +/// kind, so this note's sentence would be precisely backwards there. True only when rows were +/// actually dropped, so a complete series stays silent. +func shouldExplainShortenedSkinTempSeries(leadsAbsolute: Bool, shownReadings: Int, + rowsWithEitherNumber: Int) -> Bool { + leadsAbsolute && shownReadings < rowsWithEitherNumber +} + // MARK: - Root: categorized list /// The "Explore" picker — categories as sections, metrics as rows, each pushing a @@ -526,11 +564,18 @@ struct MetricDetailView: View { // Effort display scale (#268) — routes the Effort metric's numbers + unit; display-only, the plotted // series stays 0–100. Every other metric is scale-agnostic (see MetricDescriptor.format). @AppStorage(UnitPrefs.effortScaleKey) private var effortScaleRaw = EffortScale.hundred.rawValue + /// #1846/#1848: which skin-temp number the explorer leads with — absent/`""` = a temperature + /// (the default), or `SkinTempDisplay.Kind.deviation.rawValue` to lead with the ±baseline move. + /// Same key as Today/Health/Settings; display-only, nothing stored ever changes. + @AppStorage(UnitPrefs.skinTempDisplayKey) private var skinTempDisplayRaw = "" private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } private var temperatureUnit: TemperatureUnit { UnitPrefs.resolveTemperature(system: unitSystem, override: temperatureRaw) } private var effortScale: EffortScale { UnitPrefs.resolveEffortScale(effortScaleRaw) } + private var skinTempPreferred: SkinTempDisplay.Kind { + SkinTempDisplay.Kind(rawValue: skinTempDisplayRaw) ?? .absolute + } private func fmt(_ v: Double) -> String { metric.format(v, system: unitSystem, temperature: temperatureUnit, effortScale: effortScale) } @@ -557,6 +602,13 @@ struct MetricDetailView: View { /// reads it, so it can lag the rest of the screen by a second without anyone noticing. @State private var correlationsLoaded = false + /// #1848: the skin-temp explorer's explanatory note (nil when none is needed). Set in `load()` + /// alongside the series, from the same `repo.days` scan that decides which kind to lead with. + /// Two notes, mutually exclusive: (1) the user asked for temperatures but the window has none, + /// so deviations are shown instead; (2) leading with absolutes dropped deviation-only nights + /// from the series. Both are real on Apple — the same two cases the Android twin carries. + @State private var skinTempNote: String? = nil + /// Cached correlation scan, keyed by its inputs (selected range + the metric id), /// so the full cross-catalog Pearson sweep runs ONLY when those change — not on /// every body re-eval (hover / 1 Hz HR ticks / animation). Recomputed from @@ -564,7 +616,7 @@ struct MetricDetailView: View { @State private var correlationCache: [CorrRow] = [] /// The (metricID, range) the cache was built for; nil means "not yet computed". @State private var correlationKey: String? = nil - private var loadTaskID: String { "\(metric.id)|\(repo.refreshSeq)" } + private var loadTaskID: String { "\(metric.id)|\(repo.refreshSeq)|\(skinTempDisplayRaw)" } // MARK: Derived @@ -734,6 +786,23 @@ struct MetricDetailView: View { // with the range pill. Then the frosted chart / stat tiles / correlations. heroHeader(effectiveRange: effRange, windowed: win, windowFellBack: fellBack) heroChart(effectiveRange: effRange, windowed: win, windowFellBack: fellBack) + // #1848: the skin-temp explorer's explanatory note (nil for every other metric and + // for a skin-temp screen that needs no explanation). Sits between the chart and the + // stats so it reads as context for the series just plotted, not as a generic banner. + if let note = skinTempNote { + NoopCard { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "info.circle") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(StrandPalette.textTertiary) + .accessibilityHidden(true) + Text(note) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } statRow(effectiveRange: effRange, windowed: win) readingsTable(windowed: win) correlationCard @@ -840,6 +909,81 @@ struct MetricDetailView: View { series = byDay.sorted { $0.key < $1.key }.map { (day: $0.key, value: $0.value) } } } + // #1848: skin-temp explorer — give it a skin-temp-specific branch mirroring the Android + // `buildVitalDetail` "skin" case, rather than the shared `dailyColumn` → `skinTempDevC` path. + // + // The shared mapper returns `skinTempDevC` only, so the explorer never leads with the measured + // absolute (`skinTempC`) and ignores the Settings choice (#1846). Repointing `dailyColumn`'s + // `skin_temp` case would change which series Trends plots and what the band is computed against + // (its callers include the trend series and the metric-catalog availability check), so the fix + // is a branch HERE, not a change to the shared mapper. + // + // The preference applies across the WINDOW (#1850), not just the newest row: a wearer with + // twenty stored temperatures and one recent night without saw twenty-three deltas — the setting + // says Temperature and the app HAS temperatures. `leadReading`'s rule lifted to the window: the + // chosen kind wins whenever any night carries it, the other is still the fallback, so a choice + // can never empty the screen. + // + // An absolute may live in EITHER column (#622: a WHOOP CSV import writes absolute °C into + // `skinTempDevC`), so both count here and in the series below. + skinTempNote = nil + if metric.key == "skin_temp" { + let days = repo.days + let anyAbsolute = days.contains { row in + row.skinTempC != nil + || row.skinTempDevC.map(VitalBands.isAbsoluteSkinTemp) == true + } + let anyDeviation = days.contains { row in + row.skinTempDevC.map { !VitalBands.isAbsoluteSkinTemp($0) } == true + } + if anyAbsolute || anyDeviation { + let leadsAbsolute: Bool + switch skinTempPreferred { + case .absolute: leadsAbsolute = anyAbsolute + case .deviation: leadsAbsolute = !anyDeviation && anyAbsolute + } + if leadsAbsolute { + // An absolute-led series takes EVERY absolute reading, whichever column holds it. + // `skinTempC` is the on-device computed absolute; `skinTempDevC` may hold a CSV-imported + // absolute (#622 bimodal). Mixing is only unsound ACROSS scales; these are the same scale. + series = days.compactMap { row in + let v = row.skinTempC + ?? row.skinTempDevC.flatMap { VitalBands.isAbsoluteSkinTemp($0) ? $0 : nil } + return v.map { (day: row.day, value: $0) } + }.sorted { $0.day < $1.day } + } else { + // Genuine deviations only — an imported absolute sitting in `skinTempDevC` belongs to + // the other scale and is excluded, exactly as before. + series = days.compactMap { row in + row.skinTempDevC.flatMap { !VitalBands.isAbsoluteSkinTemp($0) ? $0 : nil } + .map { (day: row.day, value: $0) } + }.sorted { $0.day < $1.day } + } + // Rebuild sourceByDay for the new series. `DailyMetric` has no per-row deviceId on Apple + // (the merged cache doesn't carry one), so every reading attributes to the metric's own + // source — the same source the `exploreSeries` daily-column layer would have used. + sourceByDay = Dictionary(series.map { ($0.day, metric.source) }, + uniquingKeysWith: { first, _ in first }) + // The two #1847 notes — both cases exist on Apple too: + // (1) Settings asked for a temperature and none of these nights has one → say so, because + // silently falling back is why the setting reads as broken. Nights scored before + // `skinTempC` shipped kept only the deviation; a scoring pass refills them. + // (2) Leading with the absolute drops deviation-only nights from the series — which can + // now include the most recent one. Say why rather than letting history look like it + // vanished. (Deviation-led drops are the OPPOSITE kind, so the note is gated to + // absolute-led only — see `shouldExplainShortenedSkinTempSeries`.) + let shownReadings = series.count + let rowsWithEither = days.count { $0.skinTempC != nil || $0.skinTempDevC != nil } + if shouldExplainSkinTempFallback(prefer: skinTempPreferred, leadsAbsolute: leadsAbsolute, + anyAbsoluteInWindow: anyAbsolute) { + skinTempNote = String(localized: "No measured temperature for these nights — showing the difference from your baseline instead. A re-score refills temperatures for nights that have one.") + } else if shouldExplainShortenedSkinTempSeries(leadsAbsolute: leadsAbsolute, + shownReadings: shownReadings, + rowsWithEitherNumber: rowsWithEither) { + skinTempNote = String(localized: "Only nights with a measured temperature are shown — the others only have a baseline difference.") + } + } + } // #943 selection seam: a locked default (.month with under a week of history) no longer // OVERWRITES @State range - it renders through `coercedSelection` instead (non-destructive, // recomputed every body eval), so a shrinking history re-coerces and a growing one un-coerces @@ -1074,11 +1218,31 @@ struct MetricDetailView: View { valueFormat: { fmt($0) } ) } footer: { - ChartFooter([ - ("Window", effectiveRange.label), - ("Points", "\(windowed.count)"), - ("Latest", heroValue), - ]) + // #1662: the VO₂max line is SPLIT on purpose wherever the estimator changes, so two + // non-adjacent Nes runs are never joined across an incompatible Uth stretch. Nothing said so, + // and a silent gap in a trend is indistinguishable from a rendering fault — it was reported + // as "something weird with a broken line". Shown only when a break actually exists, so it + // explains the chart in front of the reader rather than a behaviour they cannot see. + // + // In the FOOTER, not beside the chart: `ChartCard` applies `chart().frame(height:)` to that + // whole closure, so a caption in there would be squeezed into the chart's fixed height and + // steal space from the line it is explaining. The footer is the only full-height slot under + // the chart. Android places it directly under the plot because its card has no such frame - + // feature parity, not pixel parity. + VStack(alignment: .leading, spacing: 8) { + if metric.key == "vo2max_est", + vo2MaxTrendHasBreak(days: windowed.map(\.day), sourceByDay: sourceByDay) { + Text("The line breaks where the estimation method changed or was not recorded.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .frame(maxWidth: .infinity, alignment: .leading) + } + ChartFooter([ + ("Window", effectiveRange.label), + ("Points", "\(windowed.count)"), + ("Latest", heroValue), + ]) + } } } @@ -1179,7 +1343,14 @@ struct MetricDetailView: View { let readings = windowed.map { VitalReading(day: $0.day, value: $0.value, source: sourceByDay[$0.day] ?? metric.source) } - let rows = vitalReadingRows(readings: readings, unit: metric.unit, + // The unit is passed EMPTY on purpose (#1942). `vitalReadingRows` appends its `unit` to whatever + // the formatter returns, and every `MetricDescriptor.format` overload already ends in the unit — + // the CONVERTED one, at that. Passing `metric.unit` here rendered "33 % %", and worse than a + // repeat for the three convertible families, whose stored label contradicts the displayed one: + // "182.0 lb kg", "Δ0.5 °F °C", "12.5 /21 /100". The Android twin appends the same way and is + // correct because every one of its call sites passes a UNIT-LESS closure; iOS has no unit-less + // formatter that also converts, so the unit comes from `fmt` and the parameter stays empty. + let rows = vitalReadingRows(readings: readings, unit: "", strapDeviceId: repo.deviceId, format: fmt) if !rows.isEmpty { NoopCard { diff --git a/Strand/Screens/NightDetailCard.swift b/Strand/Screens/NightDetailCard.swift index 57dafb5eca..dcd0a029af 100644 --- a/Strand/Screens/NightDetailCard.swift +++ b/Strand/Screens/NightDetailCard.swift @@ -1,4 +1,5 @@ import SwiftUI +import StrandAnalytics import StrandDesign import WhoopStore @@ -40,8 +41,8 @@ struct NightDetailCard: View { StatTile( label: "Sleep Debt", value: debt.latest.map { durationText($0) } ?? "—", - caption: debtCaption(debt.latest), - accent: debtColor(debt.latest), + caption: nightDetailDebtCaption(debt.latest), + accent: nightDetailDebtColor(debt.latest), sparkline: spark(debt.series), sparkColor: StrandPalette.metricRose) .frame(maxWidth: .infinity) @@ -52,7 +53,8 @@ struct NightDetailCard: View { StatTile( label: "Rest", value: pctValue(perf.latest), - caption: vsTypical(perf.latest, perf.typical, suffix: "%"), + caption: tileCaption(latestDay: perf.latestDay, latest: perf.latest, + typical: perf.typical, suffix: "%"), accent: perf.latest.map { StrandPalette.recoveryColor($0) } ?? StrandPalette.textPrimary, sparkline: spark(perf.series), sparkColor: StrandPalette.restColor) @@ -60,7 +62,8 @@ struct NightDetailCard: View { StatTile( label: "Efficiency", value: pctValue(eff.latest), - caption: vsTypical(eff.latest, eff.typical, suffix: "%"), + caption: tileCaption(latestDay: eff.latestDay, latest: eff.latest, + typical: eff.typical, suffix: "%"), accent: StrandPalette.statusPositive, sparkline: spark(eff.series), sparkColor: StrandPalette.statusPositive) @@ -68,7 +71,8 @@ struct NightDetailCard: View { StatTile( label: "Consistency", value: pctValue(cons.latest), - caption: vsTypical(cons.latest, cons.typical, suffix: "%"), + caption: tileCaption(latestDay: cons.latestDay, latest: cons.latest, + typical: cons.typical, suffix: "%"), accent: cons.latest.map { StrandPalette.recoveryColor($0) } ?? StrandPalette.textPrimary, sparkline: spark(cons.series), sparkColor: StrandPalette.metricCyan) @@ -76,7 +80,8 @@ struct NightDetailCard: View { StatTile( label: "Hours vs Needed", value: pctValue(need.latest), - caption: vsTypical(need.latest, need.typical, suffix: "%"), + caption: tileCaption(latestDay: need.latestDay, latest: need.latest, + typical: need.typical, suffix: "%"), accent: need.latest.map { StrandPalette.recoveryColor(min(100, $0)) } ?? StrandPalette.textPrimary, sparkline: spark(need.series), sparkColor: StrandPalette.restColor) @@ -84,7 +89,8 @@ struct NightDetailCard: View { StatTile( label: "Restorative", value: pctValue(rest.latest), - caption: vsTypical(rest.latest, rest.typical, suffix: "%"), + caption: tileCaption(latestDay: rest.latestDay, latest: rest.latest, + typical: rest.typical, suffix: "%"), accent: StrandPalette.sleepREM, sparkline: spark(rest.series), sparkColor: StrandPalette.sleepREM) @@ -92,7 +98,8 @@ struct NightDetailCard: View { StatTile( label: "Respiratory", value: rrValue(resp.latest), - caption: vsTypical(resp.latest, resp.typical, suffix: " rpm", decimals: 1), + caption: tileCaption(latestDay: resp.latestDay, latest: resp.latest, + typical: resp.typical, suffix: " rpm", decimals: 1), accent: StrandPalette.metricPurple, sparkline: spark(resp.series), sparkColor: StrandPalette.metricPurple) @@ -103,8 +110,8 @@ struct NightDetailCard: View { StatTile( label: "Sleep Debt", value: debt.latest.map { durationText($0) } ?? "—", - caption: debtCaption(debt.latest), - accent: debtColor(debt.latest), + caption: nightDetailDebtCaption(debt.latest), + accent: nightDetailDebtColor(debt.latest), sparkline: spark(debt.series), sparkColor: StrandPalette.metricRose) #endif @@ -122,6 +129,17 @@ struct NightDetailCard: View { v.map { String(format: "%.1f", $0) } ?? "—" } + /// #1946: a carried prior-day value is stamped "Carried · " instead of "vs typical", so it + /// is never passed off as tonight's read. Falls through to `vsTypical` when the value is today's + /// own (or there is no value). + private func tileCaption(latestDay: String?, latest: Double?, typical: Double?, + suffix: String, decimals: Int = 0) -> String { + if let carried = SleepModel.carriedMetricCaption(latestDay: latestDay, latest: latest) { + return carried + } + return vsTypical(latest, typical, suffix: suffix, decimals: decimals) + } + /// "+12% vs typical" / "−0.4 rpm vs typical" — the latest-vs-mean caption every tile carries. private func vsTypical(_ latest: Double?, _ typical: Double?, suffix: String, decimals: Int = 0) -> String { guard let latest, let typical, typical != 0 else { return String(localized: "vs typical - ") } @@ -132,20 +150,6 @@ struct NightDetailCard: View { return String(localized: "\(sign)\(num)\(suffix) vs typical") } - private func debtCaption(_ debt: Double?) -> String { - guard let debt else { return String(localized: "vs need") } - return debt < 15 ? String(localized: "On target") : String(localized: "Below need") - } - - private func debtColor(_ debt: Double?) -> Color { - guard let debt else { return StrandPalette.textPrimary } - switch debt { - case ..<15: return StrandPalette.statusPositive - case ..<60: return StrandPalette.statusWarning - default: return StrandPalette.statusCritical - } - } - /// A sparkline needs at least two points; otherwise return nil so the tile stays clean. private func spark(_ series: [Double]) -> [Double]? { let tail = Array(series.suffix(30)) @@ -160,3 +164,17 @@ struct NightDetailCard: View { return String(localized: "\(m / 60)h \(m % 60)m") } } + +func nightDetailDebtCaption(_ debt: Double?) -> String { + guard let debt else { return String(localized: "vs need") } + return debt < SleepDebt.onTargetBandMin ? String(localized: "On target") : String(localized: "Below need") +} + +func nightDetailDebtColor(_ debt: Double?) -> Color { + guard let debt else { return StrandPalette.textPrimary } + switch debt { + case .. 7 * 86_400) + } + } + } + + private var sessionsSection: some View { + VStack(alignment: .leading, spacing: NoopMetrics.space3) { + Text("Recorded sessions").font(StrandFont.title2).foregroundStyle(StrandPalette.textPrimary) + if store.sessions.isEmpty { + Text("No sessions recorded yet.").font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textSecondary) + } else { + NoopButton("Delete all sessions", systemImage: "trash", kind: .destructive, + fullWidth: true) { confirmDeleteAll = true } + .disabled(store.active != nil) + ForEach(store.sessions) { session in sessionCard(session) } + } + } + } + + private func sessionCard(_ session: RawDataSessionStore.Session) -> some View { + let coverageText = imuCoverage[session.id, default: "no complete seconds"] + return StrandCard { + VStack(alignment: .leading, spacing: NoopMetrics.space3) { + Text(Self.range(session)).font(StrandFont.headline).foregroundStyle(StrandPalette.textPrimary) + if !session.active, let endMs = session.endedAtMs { + DatePicker("From", selection: Binding( + get: { Date(timeIntervalSince1970: Double(session.startedAtMs) / 1_000) }, + set: { store.setRange(sessionId: session.id, from: $0, + to: Date(timeIntervalSince1970: Double(endMs) / 1_000)) } + )) + DatePicker("To", selection: Binding( + get: { Date(timeIntervalSince1970: Double(endMs) / 1_000) }, + set: { store.setRange(sessionId: session.id, + from: Date(timeIntervalSince1970: Double(session.startedAtMs) / 1_000), to: $0) } + )) + } + Text(session.active ? String(localized: "Export status: recording") + : String(localized: "IMU: \(coverageText)")) + .font(StrandFont.caption) + .foregroundStyle(session.active ? StrandPalette.statusWarning : StrandPalette.statusPositive) + if let exportedAt = session.lastExportedAtMs { + Text(String(localized: "Last exported \(Self.time(exportedAt)) · export remains available")) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + } + TextField("Session comment", text: Binding( + get: { store.sessions.first(where: { $0.id == session.id })?.comment ?? session.comment }, + set: { store.setComment($0, sessionId: session.id) } + ), axis: .vertical) + .textFieldStyle(.roundedBorder).lineLimit(2...4) + NoopButton("Add marker", systemImage: "mappin.and.ellipse", kind: .secondary, + fullWidth: true) { editMarker(nil, in: session) } + let markers = session.events.filter { $0.kind == "marker" }.sorted { $0.atMs < $1.atMs } + ForEach(markers) { marker in + Button { editMarker(marker, in: session) } label: { + HStack { + VStack(alignment: .leading, spacing: NoopMetrics.space1) { + (Text(Self.markerLabel(marker.markerType)) + + Text(verbatim: " · \(Self.time(marker.atMs))")) + .font(StrandFont.subhead).foregroundStyle(StrandPalette.textPrimary) + if let text = marker.text, !text.isEmpty { + Text(text).font(StrandFont.caption).foregroundStyle(StrandPalette.textSecondary) + } + } + Spacer() + Image(systemName: "chevron.right").foregroundStyle(StrandPalette.textTertiary) + } + } + .buttonStyle(.plain) + } + NoopButton(exportingId == session.id ? "Building export…" : "Export session", + systemImage: "square.and.arrow.up", kind: .secondary, fullWidth: true) { + Task { await export(session) } + } + .disabled(session.active || exportingId != nil) + NoopButton("Delete session", systemImage: "trash", kind: .destructive, + fullWidth: true) { deleteCandidate = session } + .disabled(session.active || exportingId != nil) + } + } + } + + private func markerSheet(_ initial: MarkerDraft) -> some View { + NavigationStack { + if let binding = Binding($markerDraft) { + let session = store.sessions.first(where: { $0.id == binding.wrappedValue.sessionId }) + Form { + Section { + TimelineView(.periodic(from: .now, by: 1)) { timeline in + let current = Self.markerCurrentTime(session: session, now: timeline.date) + Text("Marker: \(binding.wrappedValue.at.formatted(date: .omitted, time: .standard))") + Text("Current time: \(current.formatted(date: .omitted, time: .standard))") + .foregroundStyle(.secondary) + HStack { + Button { binding.wrappedValue.at.addTimeInterval(-10) } label: { Text(verbatim: "−10 s") } + Spacer() + Button("0") { + binding.wrappedValue.at = Self.markerCurrentTime(session: session, now: Date()) + } + Spacer() + Button { binding.wrappedValue.at.addTimeInterval(10) } label: { Text(verbatim: "+10 s") } + } + } + } + Section("Marker type") { + Picker("Marker type", selection: binding.type) { + ForEach(RawDataSessionStore.markerTypes, id: \.self) { type in + Text(Self.markerLabel(type)).tag(type) + } + }.pickerStyle(.segmented) + } + Section("Marker note") { + TextField("Marker note", text: binding.text, axis: .vertical).lineLimit(2...4) + } + if let markerId = binding.wrappedValue.markerId { + Section { + Button("Delete marker", role: .destructive) { + store.deleteMarker(sessionId: binding.wrappedValue.sessionId, markerId: markerId) + markerDraft = nil + } + } + } + } + .navigationTitle(initial.markerId == nil ? "Add marker" : "Edit marker") + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { markerDraft = nil } } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { saveMarker(binding.wrappedValue) } + } + } + } + } + } + + private func editMarker(_ marker: RawDataSessionStore.Event?, in session: RawDataSessionStore.Session) { + let currentMs = session.endedAtMs ?? Int64(Date().timeIntervalSince1970 * 1_000) + markerDraft = MarkerDraft(sessionId: session.id, markerId: marker?.markerId, + at: Date(timeIntervalSince1970: Double(marker?.atMs ?? currentMs) / 1_000), + type: marker?.markerType ?? "moment", text: marker?.text ?? "") + } + + private func saveMarker(_ draft: MarkerDraft) { + if let markerId = draft.markerId { + store.updateMarker(sessionId: draft.sessionId, markerId: markerId, + at: draft.at, type: draft.type, text: draft.text) + } else { + store.addMarker(sessionId: draft.sessionId, at: draft.at, type: draft.type, text: draft.text) + } + markerDraft = nil + } + + private func start() { + guard let session = store.start(deviceId: model.ble.deviceId) else { return } + guard model.ble.startGroundTruthRawCapture(sessionId: session.id) else { + store.stop(); store.removeMetadata(session.id); return + } + } + + private func stop() async { + await model.ble.stopGroundTruthRawCapture() + store.stop() + await refreshImuCoverage() + } + + private func export(_ session: RawDataSessionStore.Session) async { + guard let end = session.endedAtMs else { return } + exportingId = session.id + let bounds = Self.fullSecondBounds(fromMs: session.startedAtMs, toMs: end) + let from = bounds?.from ?? 1, to = bounds?.to ?? 0 + let segments = bounds.map { + ImuSessionFileStore.shared.exportSegments(session.id, from: $0.from, to: $0.to) + } ?? [] + let history = bounds == nil ? Data("stream,unix_s,v1,v2,v3,v4\n".utf8) + : await model.ble.groundTruthHistoryCSV(from: from, to: to) + let sensorAvailable = !segments.isEmpty || history.split(separator: 0x0A).count > 1 + var entries = store.exportEntries(for: session, sensorAvailable: sensorAvailable) + entries.append(.init(name: "history-sensors.csv", data: history)) + // A live capture can only produce its first complete one-second frame after startup. + // Historical windows must still cover the exact requested start. + let firstImuTs = segments.map(\.startTs).min().flatMap { $0 <= from + 1 ? $0 : nil } + let coverageFrom = session.capturedStartedAtMs == nil ? from : max(from, firstImuTs ?? from) + let imuComplete = Self.covers(segments, from: coverageFrom, to: to) + let coverage: [String: Any] = [ + "requested_start_ts": from, "requested_end_ts": to, + "required_start_ts": coverageFrom, + "startup_seconds": max(0, coverageFrom - from), + "complete": imuComplete, + "segments": segments.map { ["file": $0.name, "start_ts": $0.startTs, "end_ts": $0.endTs, + "sample_count": $0.sampleCount] } + ] + if let data = try? JSONSerialization.data(withJSONObject: coverage, options: [.prettyPrinted, .sortedKeys]) { + entries.append(.init(name: "imu-coverage.json", data: data)) + } + for segment in segments { entries.append(.init(name: "imu/\(segment.name)", data: segment.data)) } + let result = await FileExport.exportBundle(entries: entries, + suggestedName: "noop-5mg-raw-\(session.id).zip") + if result == nil { exportError = "The export file could not be created or shared." } + else { + store.markExported(session.id) + } + exportingId = nil + } + + private func delete(_ session: RawDataSessionStore.Session) async { + guard session.endedAtMs != nil else { return } + guard store.removeMetadata(session.id) else { + deleteError = "The captured data could not be deleted. The session was kept so you can retry." + return + } + imuCoverage[session.id] = nil + } + + private func deleteAll() async { + for session in store.sessions where !session.active { await delete(session) } + } + + private func refreshImuCoverage() async { + for session in store.sessions { + guard let end = session.endedAtMs, + let bounds = Self.fullSecondBounds(fromMs: session.startedAtMs, toMs: end) else { continue } + let stats = ImuSessionFileStore.shared.stats(session.id, from: bounds.from, to: bounds.to) + let first = stats.firstTs.flatMap { $0 <= Int64(bounds.from + 1) ? Int($0) : nil } + let from = session.capturedStartedAtMs == nil ? bounds.from : max(bounds.from, first ?? bounds.from) + let expected = max(0, bounds.to - from + 1) + let bytes = ByteCountFormatter.string(fromByteCount: stats.bytes, countStyle: .file) + let readiness = expected > 0 && stats.coveredSeconds == expected ? "ready" : "incomplete" + imuCoverage[session.id] = "\(stats.coveredSeconds)/\(expected) s · \(bytes) · \(readiness)" + } + } + + private static func time(_ ms: Int64) -> String { + Date(timeIntervalSince1970: Double(ms) / 1_000) + .formatted(Date.FormatStyle(date: .omitted, time: .shortened) + .locale(AppClock.formattingLocale)) // #1821 + } + + static func fullSecondBounds(fromMs: Int64, toMs: Int64) -> (from: Int, to: Int)? { + let from = Int((fromMs + 999) / 1_000), to = Int(toMs / 1_000) - 1 + return from <= to ? (from, to) : nil + } + + static func markerCurrentTime(session: RawDataSessionStore.Session?, now: Date) -> Date { + session?.endedAtMs.map { Date(timeIntervalSince1970: Double($0) / 1_000) } ?? now + } + + private static func range(_ session: RawDataSessionStore.Session) -> String { + let start = Date(timeIntervalSince1970: Double(session.startedAtMs) / 1_000) + let date = start.formatted(date: .numeric, time: .omitted) + let end = session.endedAtMs.map(time) ?? "…" + return "\(date) · \(time(session.startedAtMs))–\(end)" + } + + private static func markerLabel(_ type: String?) -> LocalizedStringKey { + switch type { + case "start": "Start" + case "end": "End" + case "issue": "Issue" + default: "Moment" + } + } + + private static func covers(_ chunks: [ImuSessionFileStore.ExportSegment], from: Int, to: Int) -> Bool { + var cursor = from + for chunk in chunks.sorted(by: { $0.startTs < $1.startTs }) { + let start = chunk.startTs, end = chunk.endTs + if chunk.sampleCount < (end - start + 1) * ImuSessionFileStore.sampleRate { continue } + if start > cursor { return false } + if end >= cursor { cursor = end + 1 } + if cursor > to { return true } + } + return cursor > to + } +} diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift index 7c76d02b5a..a17a8642b9 100644 --- a/Strand/Screens/SettingsView.swift +++ b/Strand/Screens/SettingsView.swift @@ -35,6 +35,10 @@ struct SettingsView: View { @State private var backupAlertTitle = "" @State private var backupAlertMessage = "" @State private var showBackupAlert = false + /// #1807: a restore refused ONLY for size is recoverable, so it gets its own two-button alert rather + /// than the shared single-OK one every other backup outcome uses. + @State private var showOversizeRestoreConfirm = false + @State private var oversizeRestoreMessage = "" /// Opt-in WHOOP 5/MG protocol experiments (off by default). See [PuffinExperiment]. @AppStorage(PuffinExperiment.defaultsKey) private var puffinExperiments = false @@ -166,11 +170,19 @@ struct SettingsView: View { /// WHOOP 4.0) regardless of this switch. See [PuffinExperiment.motionAwareWakeKey]. @AppStorage(PuffinExperiment.motionAwareWakeKey) private var motionAwareWakeEnabled = false - // Imperial/Metric display preference (D#103). Stored data is always SI; this only changes how - // distances/weights/heights/temperatures are SHOWN — and lets the profile fields below take - // imperial entry. Temperature has a separate override so °C/°F can be picked independently. + // Display preferences. `units.system` remains the body-measurement choice for compatibility; + // exercise distance/pace can override it independently. Stored data is always SI. + /// #1821: Clock format. Defaults to `.system`, so upgrading changes nobody's displayed times. + /// #1841: shared with Android by name and meaning; each platform keeps its own store. Default FALSE + /// on Apple (Android defaults true) because the system behaviour may not fire on our + /// `NavigationStack(path:)` tabs — see RootTabView. + @AppStorage("noop.bottomBarAutoHide") private var bottomBarAutoHide = false + @AppStorage(ClockFormatPreference.defaultsKey) + private var clockFormatRaw = ClockFormatPreference.system.rawValue @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue + @AppStorage(UnitPrefs.distanceSystemKey) private var distanceSystemRaw = "" @AppStorage(UnitPrefs.temperatureKey) private var temperatureRaw = "" + @AppStorage(UnitPrefs.skinTempDisplayKey) private var skinTempDisplayRaw = "" // #1846 // Effort display scale (#268). Display-only — Effort stays stored 0–100, this only chooses whether // it's shown on NOOP's 0–100 axis or WHOOP's 0–21 Day Strain axis. @AppStorage(UnitPrefs.effortScaleKey) private var effortScaleRaw = EffortScale.hundred.rawValue @@ -178,6 +190,7 @@ struct SettingsView: View { @AppStorage(UnitPrefs.hrvWindowKey) private var hrvWindowRaw = HrvWindow.whole.rawValue // Live-HR Live Activity (Lock Screen + Dynamic Island), iOS only (#336). Default on. @AppStorage(UnitPrefs.liveActivityKey) private var liveActivityEnabled = true + @AppStorage(DayCycleMode.storageKey) private var dayCycleModeRaw = DayCycleMode.sleepOnset.rawValue // Alternate app icon (iOS only) — false = Titanium (primary AppIcon), true = Blue Titanium // ("AppIcon-Navy"). Display-only preference; the live switch goes through setAlternateIconName. @AppStorage("appIcon.alt") private var useNavyIcon = false @@ -244,6 +257,12 @@ struct SettingsView: View { } private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } + private var distanceUnitSystem: UnitSystem { + UnitPrefs.resolveDistance(system: unitSystem, override: distanceSystemRaw) + } + private var distanceSystemBinding: Binding { + Binding(get: { distanceUnitSystem.rawValue }, set: { distanceSystemRaw = $0 }) + } private var temperatureUnit: TemperatureUnit { UnitPrefs.resolveTemperature(system: unitSystem, override: temperatureRaw) } @@ -294,6 +313,9 @@ struct SettingsView: View { /// User-initiated GitHub release check behind the About "Check for updates" button. @StateObject private var updateChecker = UpdateChecker() + /// #1659. Default comes from `UpdateAvailability.defaultEnabled` so the toggle and the launch check + /// cannot disagree about what "unset" means. + @AppStorage(UpdateWatch.Keys.enabled) private var autoCheckUpdates = UpdateAvailability.defaultEnabled @Environment(\.openURL) private var openURL /// Whether the "Advanced" disclosure (Recovery, Test Centre, experimental probes, Backup & @@ -345,6 +367,15 @@ struct SettingsView: View { } message: { Text(backupAlertMessage) } + // Title and buttons reuse catalogue strings that already carry all nine locales, rather than + // minting new copy that would ship English everywhere until someone translated it. The message + // below is where the specifics live. (#1807) + .alert("Backup problem", isPresented: $showOversizeRestoreConfirm) { + Button("Restore") { runImport(allowOversize: true) } + Button("Cancel", role: .cancel) { } + } message: { + Text(oversizeRestoreMessage) + } .confirmationDialog("Recalibrate your Charge baseline?", isPresented: $showRecalibrateConfirm, titleVisibility: .visible) { Button("Recalibrate") { recalibrateHrvBaseline() } @@ -514,6 +545,27 @@ struct SettingsView: View { } } rowDivider + FormRow(label: "Day cycle") { + Picker("Day starts", selection: Binding( + get: { DayCycleMode.persisted(dayCycleModeRaw) }, + set: { mode in + dayCycleModeRaw = mode.rawValue + Task { await model.intelligence.analyzeRecent(); await model.repo.refresh() } + } + )) { + Text("Main sleep").tag(DayCycleMode.sleepOnset) + Text("00:00").tag(DayCycleMode.midnight) + } + .labelsHidden() + .pickerStyle(.menu) + } + Text(dayCycleModeRaw == DayCycleMode.midnight.rawValue + ? "Uses a conventional local calendar day from 00:00 to 00:00." + : "Default. Steps and in-progress Effort restart at the beginning of detected main sleep. Naps do not start a new day; missing sleep falls back to local midnight.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + rowDivider // Step calibration (#139/#132): daily steps = @57 counter ticks ÷ this divisor. // 1.0 = raw pass-through until the true 5/MG tick rate is known. The divisor goes // up to 30 because a 5/MG motion counter can overcount by ~24×; the stepper uses a @@ -938,31 +990,41 @@ struct SettingsView: View { // MARK: - Units - /// Imperial/Metric display toggle + a separate temperature override. Display-only — nothing stored - /// changes, NOOP keeps everything in SI and converts at the point of display. + /// Independent body and exercise-distance unit choices plus temperature and Effort overrides. + /// Display-only — nothing stored changes; NOOP keeps everything in SI. private var unitsCard: some View { SettingsSection( icon: "ruler", title: "Units", - blurb: "Choose how distances, weights, heights, temperatures and Effort are shown. Your data is always stored the same way. This only changes the display." + blurb: "Choose body measurements and exercise distance separately. Your data is always stored the same way; these settings only change its display." ) { VStack(spacing: 0) { - FormRow(label: "Measurement system") { - Picker("Measurement system", selection: $unitSystemRaw) { + FormRow(label: "Body measurements") { + Picker("Body measurements", selection: $unitSystemRaw) { Text("Metric").tag(UnitSystem.metric.rawValue) Text("Imperial").tag(UnitSystem.imperial.rawValue) } .labelsHidden() .pickerStyle(.menu) .tint(StrandPalette.accent) - .accessibilityLabel("Measurement system") + .accessibilityLabel("Body measurement units") + } + rowDivider + FormRow(label: "Exercise distance & pace") { + Picker("Exercise distance & pace", selection: distanceSystemBinding) { + Text("Kilometres").tag(UnitSystem.metric.rawValue) + Text("Miles").tag(UnitSystem.imperial.rawValue) + } + .labelsHidden() + .pickerStyle(.menu) + .tint(StrandPalette.accent) + .accessibilityLabel("Exercise distance and pace units") } rowDivider FormRow(label: "Temperature") { - // Three-way: "Match" follows the system above; °C / °F pin it explicitly. Stored as - // an empty string ("match") or the TemperatureUnit raw value. + // Three-way: "Follow body" follows body measurements; °C / °F pin it explicitly. Picker("Temperature", selection: $temperatureRaw) { - Text("Match").tag("") + Text("Follow body").tag("") Text("°C").tag(TemperatureUnit.celsius.rawValue) Text("°F").tag(TemperatureUnit.fahrenheit.rawValue) } @@ -972,6 +1034,20 @@ struct SettingsView: View { .accessibilityLabel("Temperature unit") } rowDivider + FormRow(label: "Skin temperature") { + // #1846: lead with a temperature ("33.5 °C") or with the move from your own baseline + // ("-0.1 Δ°C"). Only a PREFERENCE — a night that measured just one of the two still + // shows that one, so the choice can never blank a card. + Picker("Skin temperature", selection: $skinTempDisplayRaw) { + Text("Temperature").tag("") + Text("vs baseline").tag(SkinTempDisplay.Kind.deviation.rawValue) + } + .labelsHidden() + .pickerStyle(.menu) + .tint(StrandPalette.accent) + .accessibilityLabel("Skin temperature display") + } + rowDivider // Effort scale (#268) — show NOOP's native 0–100 Effort or WHOOP's 0–21 Day Strain axis. // Display-only; the stored value never changes, so a flip just re-labels every Effort read-out. FormRow(label: "Effort scale") { @@ -1107,6 +1183,41 @@ struct SettingsView: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(.top, NoopMetrics.space1) rowDivider + // #1821: sits with Language rather than in Units because it is an app-owned display + // CONVENTION, not a unit of measurement — and like Language it offers "System default", + // which here means the device's own 24-Hour Time switch rather than the region default. + // Unlike Language this needs no relaunch: the formatter caches per resolved template. + FormRow(label: "Clock") { + Picker("Clock", selection: $clockFormatRaw) { + Text("System default").tag(ClockFormatPreference.system.rawValue) + Text("12-hour").tag(ClockFormatPreference.twelveHour.rawValue) + Text("24-hour").tag(ClockFormatPreference.twentyFourHour.rawValue) + } + .labelsHidden() + .pickerStyle(.menu) + .tint(StrandPalette.accent) + .accessibilityLabel("Clock") + // #1829: the resolved clock is memoised, so the write has to drop the memo or the + // picker would appear to do nothing until the app restarted. + .onChangeCompat(of: clockFormatRaw) { _ in AppClock.invalidate() } + } + #if os(iOS) + rowDivider + // #1841: the same preference Android drives its own bar with, by name and meaning. Here + // the SYSTEM owns the behaviour — iOS 26 minimises the tab bar to a pill on scroll rather + // than sliding it away — so this asks for the platform's reading of the intent rather + // than reproducing ours. Below iOS 26 the modifier is inert and the row simply does + // nothing, which is why it is not offered there. + if #available(iOS 26.0, *) { + FormRow(label: "Hide bar when scrolling") { + Toggle("", isOn: $bottomBarAutoHide) + .labelsHidden() + .tint(StrandPalette.accent) + .accessibilityLabel("Hide bar when scrolling") + } + } + #endif + rowDivider // Theme presets — one-tap bundles coordinating accent + chart world + backdrop + card // opacity. Derived (no stored value): tweaking any control below flips this to Custom. FormRow(label: "Preset") { @@ -1477,14 +1588,21 @@ struct SettingsView: View { // // A live-HR link falls through to the pairing hint when one is set, and otherwise to "Finishing // the secure pairing handshake…", which is accurate HERE because this platform still retries the - // CLIENT_HELLO on every connect. Android has an explicit "streaming but not fully paired" arm - // instead, because #1635 suppression stops it retrying and nothing is finishing any more. If that - // suppression is ported here, this branch must gain the same arm or it starts describing a - // handshake that is no longer being attempted. + // CLIENT_HELLO on every connect. The #1635 suppression is now ported here too, so once it latches + // nothing is finishing any more and the old fall-through would describe a handshake that is no + // longer being attempted. The `bonded && connected` arm below is that fix, matching the Android + // twin (`SettingsLogic.strapStatusLine`). if live.encryptedBond && live.connected { return String(localized: "Your strap is paired and sending data. Open Live for a real-time heart rate.") } + // An actionable hint outranks the generic arm: the suppression hint names the one action that + // restores the handshake, which "not fully paired" alone does not. if live.connected, let hint = live.pairingHint { return hint } + // Live HR over the UNBONDED standard profile (#69). True whenever the handshake is suppressed or + // simply has not landed, and the honest description either way. + if live.bonded && live.connected { + return String(localized: "Live heart rate is streaming, but your strap is not fully paired. The encrypted pairing is what carries motion, skin temperature, SpO₂ and respiratory rate — without it, sleep is staged from heart rate alone. Buzz, alarms and history sync need it too.") + } if live.connected { return String(localized: "Connected. Finishing the secure pairing handshake…") } if live.bonded { return String(localized: "Previously paired but not currently connected. Re-scan to reconnect.") } return String(localized: "No strap connected. Put your WHOOP nearby and tap Re-scan to pair.") @@ -1724,7 +1842,8 @@ struct SettingsView: View { @ViewBuilder private var experimentalCard: some View { liquidTodayCard liveSessionsCard - if showFiveMGControls { fiveMGCard } + // WHOOP 5/MG protocol research now lives in Test Centre. Everyday Settings no longer carries + // a second copy; the persisted keys and reversible disable actions remain unchanged there. if showFiveMGControls || model.repo.activeDeviceIsOura { spo2CandidateCard } sleepStagingCard rawSensorDiagnosticsCard @@ -1881,7 +2000,7 @@ struct SettingsView: View { SettingsSection( icon: "flask.fill", title: "Experimental · WHOOP 5 / MG", - blurb: "Live heart rate already works on a WHOOP 5/MG strap. These probes go further and try to coax more out of it. They are guesses, off by default, and only ever touch a 5/MG strap. WHOOP 4.0 is never affected." + blurb: "Normal WHOOP 5/MG recording and history sync are supported. These remaining controls are developer experiments for unmapped protocol features and now live in Test Centre." ) { VStack(alignment: .leading, spacing: NoopMetrics.rowSpacing) { Toggle(isOn: $puffinExperiments) { @@ -1910,7 +2029,7 @@ struct SettingsView: View { // strap kept every flag the enable sequence set while the UI implied it had been undone. // Now it offers the real undo. Turning it ON still writes nothing until the button is tapped. .onChangeCompat(of: deepDataEnabled) { on in if !on { confirmingDeepDataDisable = true } } - Text("WHOOP 5/MG straps hand a fresh app only live heart rate. The official app switches on the deeper streams (high-rate HR + motion + history) by writing a set of feature flags, a sequence two independent projects have documented. With this on, the button below sends that exact sequence to your strap. Unlike everything else here it does write to the strap — and it is reversible: \u{201C}Turn deep data back off\u{201D} writes the off value to the same flags and then reads every one of them back, so you see what the strap actually stores rather than just that it acked. Experimental: it may do nothing on your firmware. iPhone/Android only. A Mac can't write to a 5/MG.") + Text("Legacy R22 feature-flag experiment. The strap accepts these writes, but NOOP has not observed them enabling a separate live stream. This is not required for normal WHOOP 5/MG support or for the Raw Data Collector. It writes persistent strap settings and may do nothing on your firmware.") .font(StrandFont.caption) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) @@ -2565,10 +2684,10 @@ struct SettingsView: View { } } - private func runImport() { + private func runImport(allowOversize: Bool = false) { backupBusy = true Task { - let result = await DataBackup.runImport() + let result = await DataBackup.runImport(allowOversize: allowOversize) handleBackup(result) } } @@ -2603,6 +2722,19 @@ struct SettingsView: View { backupAlertTitle = String(localized: "Backup exported") backupAlertMessage = String(localized: "Saved to \(url.lastPathComponent). Copy this file to your other \(Platform.deviceNoun) and use Import there to restore everything.") showBackupAlert = true + case .exportedOversize(let url, let bytes, let limit): + // #1807: the file is written and worth keeping — say so first, then say what restoring it + // will ask for. The old behaviour said nothing here and refused at restore, which is the + // one moment the original is already gone. + let size = ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) + let cap = ByteCountFormatter.string(fromByteCount: limit, countStyle: .file) + backupAlertTitle = String(localized: "Backup exported") + backupAlertMessage = String(localized: "Saved to \(url.lastPathComponent). Your database is \(size), over the \(cap) NOOP restores without asking — the backup is complete and valid, and restoring it will ask you to confirm once.") + showBackupAlert = true + case .restoreTooLarge(let name, let limit): + let cap = ByteCountFormatter.string(fromByteCount: limit, countStyle: .file) + oversizeRestoreMessage = String(localized: "\(name) is larger than the \(cap) NOOP restores without asking. That limit guards against a malicious archive expanding to fill this \(Platform.deviceNoun) — a backup you exported yourself is not that. Restoring it needs the space the database will take. You'll be asked to choose the file again.") + showOversizeRestoreConfirm = true case .imported: backupAlertTitle = String(localized: "Backup imported") backupAlertMessage = String(localized: "Your data has been restored. Quit and reopen NOOP for it to take effect.") @@ -2620,9 +2752,7 @@ struct SettingsView: View { /// project.yml MARKETING_VERSION), so the About pill can never go stale the way a hand-edited /// Swift constant can. Mirrors how Android's pill reads BuildConfig.VERSION_NAME. Falls back to /// the hand-maintained changelog version only if the Info.plist key is somehow missing. - private var bundleVersionString: String { - (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? AppChangelog.currentVersion - } + private var bundleVersionString: String { UpdateWatch.installedVersion } private var aboutCard: some View { SettingsSection( @@ -2805,6 +2935,23 @@ struct SettingsView: View { Spacer() } + // #1659: the automatic half. iOS cannot auto-update a sideloaded build at all — no API + // lets an app install or re-sign an .ipa — so noticing and saying so is the whole of + // what is possible. ON by default, because a sideloaded app has no store to tell the + // user anything and a setting nobody finds is the feature not existing; switching it + // off here stops the request entirely. See UpdateAvailability.defaultEnabled. + Toggle(isOn: $autoCheckUpdates) { + VStack(alignment: .leading, spacing: 2) { + Text("Check automatically") + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textPrimary) + Text("Once a day, NOOP asks GitHub for the latest version number and puts a note in Updates if there's a newer one. Nothing about you is sent, and it never installs anything.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + } + } + .tint(StrandPalette.accent) + // Update available: show what's new, with a download straight to the release. if case .available(let v, let url, let notes) = updateChecker.state { VStack(alignment: .leading, spacing: 8) { @@ -3267,6 +3414,15 @@ struct StepsCalibrationSheet: View { @State private var draftManual: Double = 0 @State private var didLoad = false + /// The strap has banked no motion, and we have looked. + /// + /// Named once because two places depend on it and they must stay exactly complementary: the + /// no-motion banner appears, and the calibration countdown does NOT. Written as two separate + /// expressions they drifted immediately — the guard's first draft tested `sampleMotion == nil` + /// alone, which is also true during the load, so the countdown vanished in a window where the + /// banner had not appeared yet and the card explained nothing at all. + private var strapHasNoMotion: Bool { didLoad && sampleMotion == nil } + /// #107: the sheet's guidance depends on the strap family. A WHOOP 4.0 streams motion automatically, so /// "let it sync" is right; a 5/MG only streams motion once the experimental deep-data unlock is on, so /// the 4.0 advice is futile there and the empty state must say so instead. @@ -3287,7 +3443,7 @@ struct StepsCalibrationSheet: View { ScrollView { VStack(alignment: .leading, spacing: NoopMetrics.sectionSpacing) { explainerCard - if didLoad && sampleMotion == nil { noMotionNote } + if strapHasNoMotion { noMotionNote } currentFitCard comparisonCard manualAdjustCard @@ -3358,7 +3514,7 @@ struct StepsCalibrationSheet: View { .font(StrandFont.headline) .foregroundStyle(StrandPalette.textPrimary) Text(is5MG - ? String(localized: "NOOP estimates your steps from your WHOOP's motion, calibrated to your phone's step count. It's an estimate, not a step counter — a WHOOP 5.0 / MG streams motion (not a step count) only with deep data on.") + ? String(localized: "NOOP estimates your steps from your WHOOP's stored motion, calibrated to your phone's step count. It's an estimate, not a hardware step counter; normal WHOOP 5/MG history sync supplies the motion data.") : String(localized: "NOOP estimates your steps from your WHOOP's motion, calibrated to your phone's step count. It's an estimate, not a step counter. A WHOOP 4.0 doesn't transmit steps.")) .font(StrandFont.subhead) .foregroundStyle(StrandPalette.textSecondary) @@ -3399,7 +3555,7 @@ struct StepsCalibrationSheet: View { /// The "why it's empty" line — a 5/MG needs the deep-data unlock before it streams motion at all. private var noMotionLead: String { if is5MG { - return String(localized: "We're not seeing any motion from your WHOOP 5.0 / MG yet. Unlike a 4.0, a 5/MG only streams motion (and history) once the experimental deep-data unlock is on — so until then there's nothing to estimate steps from. Importing history from WHOOP or Apple Health doesn't provide the strap motion this needs.") + return String(localized: "We're not seeing motion from your WHOOP 5.0 / MG yet. Keep NOOP connected and let strap history finish syncing; the experimental R22 flags are not required. Account or Apple Health imports do not contain the raw strap motion this estimate needs.") } return String(localized: "We're not seeing any motion from your strap yet. Steps are estimated from your WHOOP's banked motion history, so your strap needs to sync that history before NOOP has anything to count.") } @@ -3407,7 +3563,7 @@ struct StepsCalibrationSheet: View { /// The "what to do" line — 5/MG points at the deep-data toggle (unless it's already on, then just sync). private var noMotionAction: String { if is5MG && !deepDataEnabled { - return String(localized: "Turn on Settings → \u{201C}Unlock WHOOP 5/MG deep data (R22)\u{201D}, reconnect your strap, then open NOOP near it and let a day or two of motion sync. Your step estimate and the calibration below fill in once motion lands.") + return String(localized: "Open NOOP near the strap and let WHOOP 5/MG history finish syncing. The step estimate and calibration fill in once enough stored motion has arrived; the legacy R22 experiment is not required.") } if is5MG { return String(localized: "Deep data is on — open NOOP near your strap and let it sync its motion history (a full first-run sync can take a while). Once a day or two of motion lands, your step estimate and the calibration below fill in.") @@ -3445,6 +3601,18 @@ struct StepsCalibrationSheet: View { Text("Not calibrated yet") .font(StrandFont.bodyNumber) .foregroundStyle(StrandPalette.textPrimary) + // Only ask for phone-step days when phone-step days are what is actually missing. + // + // A step estimate is `motion * coefficient` (`StepsEstimateEngine.estimate`) and a + // calibration point is the ratio `steps / motion`, so BOTH halves are required. With no + // banked strap motion neither the estimate nor the fit can move however many days the + // phone counts. The countdown below then names the half the user already has and hides + // the half they do not — a field report asked whether entering Apple Health steps by + // hand would start the calibration, which is exactly the conclusion it invites. + // + // The no-motion banner at the top of this sheet already explains the real blocker, so + // the honest move is to stop competing with it rather than to add more copy. + if !strapHasNoMotion { // #589: a concrete countdown instead of a vague "a few days". Headline comes straight // from the engine's needsMoreDays state so the wording matches the Today steps tile. // #693: drive `have` off `profile.stepsCalibrationSampleDays` — the value the engine @@ -3462,6 +3630,7 @@ struct StepsCalibrationSheet: View { .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) + } } } } @@ -3613,7 +3782,7 @@ struct StepsCalibrationSheet: View { .sorted { $0.day > $1.day } // Reconstruct the estimate for the most recent phone-covered days, motion-by-motion. - guard coeff > 0, let store = await repo.storeHandle() else { return } + guard coeff > 0 else { return } let cal = StepsEstimateEngine.Calibration(coefficient: coeff, sampleDays: profile.stepsCalibrationSampleDays, confidence: profile.stepsCalibrationConfidence, @@ -3625,8 +3794,10 @@ struct StepsCalibrationSheet: View { for entry in phoneDays.prefix(10) { // scan a few extra to fill 7 after motion gaps guard let dayDate = dayParser.date(from: entry.day) else { continue } let mid = Int(calendar.startOfDay(for: dayDate).timeIntervalSince1970) - let grav = (try? await store.gravitySamples(deviceId: repo.deviceId, from: mid, - to: mid + 86_400 - 1, limit: 200_000)) ?? [] + // #1643: the UNION, not `repo.deviceId` alone — a re-added strap leaves motion under both the + // active id and the canonical one, and reading either by itself makes this screen disagree + // with the estimator it is supposed to be reconstructing. + let grav = await repo.gravitySamplesUnion(from: mid, to: mid + 86_400 - 1) let motion = StepsEstimateEngine.dayMotionIntensity(grav) guard motion > 0, let est = StepsEstimateEngine.estimate(motion: motion, calibration: cal) else { continue } motions.append(motion) diff --git a/Strand/Screens/SkinTempCardsView.swift b/Strand/Screens/SkinTempCardsView.swift index 9c1c886264..7852ad3ca0 100644 --- a/Strand/Screens/SkinTempCardsView.swift +++ b/Strand/Screens/SkinTempCardsView.swift @@ -759,11 +759,7 @@ struct BodyClockCard: View { components.hour = hh components.minute = mm guard let date = components.date else { return "\(hh):\(String(format: "%02d", mm))" } - let formatter = DateFormatter() - formatter.locale = AppLanguage.activeLocale - formatter.dateStyle = .none - formatter.timeStyle = .short - return formatter.string(from: date) + return AppClock.hourMinute(date) // #1821 } } diff --git a/Strand/Screens/SleepCustomizationSheet.swift b/Strand/Screens/SleepCustomizationSheet.swift index 763d0e01fb..6840bed5a8 100644 --- a/Strand/Screens/SleepCustomizationSheet.swift +++ b/Strand/Screens/SleepCustomizationSheet.swift @@ -94,6 +94,7 @@ extension SleepSection { switch self { case .sleepMarks: return "bed.double" case .stages: return "chart.bar.xaxis" + case .bodyClock: return "clock.badge.checkmark" case .nightDetail: return "square.grid.2x2" case .sleepDebt: return "arrow.down.right.circle" case .stagesVsTypical: return "chart.bar" @@ -107,6 +108,7 @@ extension SleepSection { switch self { case .sleepMarks: return StrandPalette.accent case .stages: return StrandPalette.restColor + case .bodyClock: return StrandPalette.restColor case .nightDetail: return StrandPalette.restBright case .sleepDebt: return StrandPalette.effortColor case .stagesVsTypical: return StrandPalette.restColor diff --git a/Strand/Screens/SleepDebtLedgerCard.swift b/Strand/Screens/SleepDebtLedgerCard.swift index 6301394dd8..e53f99069a 100644 --- a/Strand/Screens/SleepDebtLedgerCard.swift +++ b/Strand/Screens/SleepDebtLedgerCard.swift @@ -7,14 +7,14 @@ import WhoopStore // // The Sleep tab's "Sleep-debt ledger" card, extracted into a standalone view so it can ALSO be hosted in // the Today tab. Both the Sleep tab and the Today host render THIS view from the SAME `SleepModel`, so the -// rolling 14-night running balance can never diverge between the two surfaces (the parity contract). The +// recency-weighted estimate can never diverge between the two surfaces (the parity contract). The // card body + its `debtDeltaBars` strip and the debt-only formatting helpers (`debtHeadline` / // `debtTag` / `debtRead` / `debtBalanceColor` / `debtSigned`) are a verbatim lift of the former // `SleepView.sleepDebtLedger` (and the helpers only it used); the nap-credited ledger is computed once in // `SleepModel.build` (the shared builder) and read here — it is NOT recomputed. -/// The "Sleep-debt ledger" card. A running balance of (slept − personal need) across the recent -/// fortnight — the net debt/surplus headline, a plain-English read, and a diverging per-night delta bar — +/// The "Sleep-debt ledger" card. A recency-weighted estimate across the recent fortnight — the +/// current debt headline, a plain-English read, and a diverging raw per-night delta bar — /// rendered from the shared [SleepModel]'s `sleepDebtLedger`. (#242) struct SleepDebtLedgerCard: View { let model: SleepModel @@ -52,7 +52,8 @@ struct SleepDebtLedgerCard: View { .font(StrandFont.subhead) .foregroundStyle(StrandPalette.textSecondary) .fixedSize(horizontal: false, vertical: true) - // Per-night diverging delta bars (surplus up, deficit down). + // Raw per-night delta bars (above/below base need), intentionally separate + // from the recurrence's current debt estimate. debtDeltaBars(ledger) Divider().overlay(StrandPalette.hairline) ChartFooter([ @@ -112,30 +113,30 @@ struct SleepDebtLedgerCard: View { return "≈\(durationText(m))" } - /// Short tag under/beside the headline: DEBT / SURPLUS / ON TARGET. + /// Short tag under/beside the headline: DEBT / ON TARGET. private func debtTag(_ ledger: SleepDebtLedger) -> String { if ledger.magnitudeMin < SleepDebt.onTargetBandMin { return String(localized: "balanced") } - return ledger.isDebt ? String(localized: "sleep debt") : String(localized: "surplus") + return ledger.isDebt ? String(localized: "sleep debt") : String(localized: "balanced") } - /// Plain-English read of the running balance over the window. + /// Plain-English read of the current actionable estimate. private func debtRead(_ ledger: SleepDebtLedger) -> String { let nights = ledger.nightCount let span = nights == 1 ? String(localized: "the last night") : String(localized: "the last \(nights) nights") if ledger.magnitudeMin < SleepDebt.onTargetBandMin { - return String(localized: "You're roughly on top of your sleep across \(span). Slept minutes balance out against your need.") + return String(localized: "You've effectively met your current sleep need across \(span).") } let mag = durationText(ledger.magnitudeMin) if ledger.isDebt { - return String(localized: "You've banked about \(mag) of sleep debt over \(span). Surplus nights count back against it. An earlier night or two would clear it.") + return String(localized: "Add about \(mag) to your base sleep target tonight. Meeting that complete sleep need clears the displayed debt; recent shortfalls carry forward at a reduced weight.") } - return String(localized: "You're carrying about \(mag) of surplus over \(span). You've slept past your need on balance. Nicely ahead.") + return String(localized: "You've effectively met your current sleep need across \(span).") } - /// Color the balance by sign + size: surplus/within-band → positive green, modest - /// debt → warning, heavier debt → critical. + /// Color the balance by size: balanced → positive green, modest debt → warning, + /// heavier debt → critical. private func debtBalanceColor(_ ledger: SleepDebtLedger) -> Color { if ledger.magnitudeMin < SleepDebt.onTargetBandMin || !ledger.isDebt { return StrandPalette.statusPositive diff --git a/Strand/Screens/SleepModel.swift b/Strand/Screens/SleepModel.swift index 493b3f56c7..198035ed06 100644 --- a/Strand/Screens/SleepModel.swift +++ b/Strand/Screens/SleepModel.swift @@ -118,12 +118,12 @@ struct Night { // Clock for the Asleep/Woke row — the times people read at a glance. The "jmm" skeleton // follows the device's 12-/24-hour setting ("11:42 PM" or "23:42") instead of forcing one // on everyone, matching the HR-tooltip / workout times (#337). - private static let timeFmt: DateFormatter = { - let f = DateFormatter() - f.locale = AppLanguage.activeLocale - f.setLocalizedDateFormatFromTemplate("jmm") - return f - }() + /// #1821: routed through `AppClock` so the Clock format setting reaches every night time on screen - + /// this is the formatter behind the sleep card's ASLEEP/WOKE values in the report. It used the `jmm` + /// template, whose `j` resolves the hour from the locale, which is how a reader's explicit 12-hour + /// choice was being discarded. `AppClock` caches per resolved template, so this is no more expensive + /// than the `static let` it replaces and it responds when the setting changes. + private static var timeFmt: DateFormatter { AppClock.hourMinuteFormatter() } private static let dateFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "EEE d MMM"; return f }() @@ -137,8 +137,42 @@ struct Night { /// `SleepModel.build(_:)` and read by the subviews, so full passes over the day rows / sleep sessions /// and the Night.intervals reconstruction no longer run on every render. struct SleepModel { - /// (latest, typical mean, full history) per metric — mirrors SleepView's per-tile series. - typealias Metric = (latest: Double?, typical: Double?, series: [Double]) + /// (latest, latestDay, typical mean, full history) per metric — mirrors SleepView's per-tile series. + /// `latestDay` is the yyyy-MM-dd the `latest` value was carried from (nil when there is no latest + /// value, or when the latest value IS today's). #1946: a carried prior-day value is stamped with its + /// day so it is not passed off as tonight's read. + typealias Metric = (latest: Double?, latestDay: String?, typical: Double?, series: [Double]) + + /// #1946: the caption for a metric tile whose `latest` value was carried from a prior day. + /// Returns nil when the value is NOT carried (today's own, or no value) so the caller falls through + /// to the normal "vs typical" caption. When carried, returns "Carried · " so a prior night's + /// number is never passed off as tonight's read. Pure + unit-testable. Mirror EXACTLY in Kotlin. + static func carriedMetricCaption(latestDay: String?, latest: Double?) -> String? { + guard let latestDay, latest != nil else { return nil } + return String(localized: "Carried · \(Self.shortDayLabel(latestDay))") + } + + /// "12 Jul" for a "yyyy-MM-dd" key — the SAME format `TodayView.carriedCaption` uses for the + /// recovery carry stamp, so a carried Rest on Today and a carried metric on Sleep read identically. + static func shortDayLabel(_ key: String) -> String { + guard let date = dayKeyParser.date(from: key) else { return key } + return Self.shortDayFormatter.string(from: date) + } + + private static let dayKeyParser: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(identifier: "UTC") + return f + }() + + private static let shortDayFormatter: DateFormatter = { + let f = DateFormatter() + f.locale = AppLanguage.activeLocale + f.setLocalizedDateFormatFromTemplate("dMMM") + return f + }() let night: Night /// Stage intervals for the hypnogram — computed once (Night.intervals is a computed @@ -169,8 +203,8 @@ struct SleepModel { let trendPoints: [TrendPoint] - /// Rolling 14-night sleep-debt ledger: Σ(slept − personal need) across the recent - /// fortnight, with the per-night deltas behind it. Computed once per data change. + /// Recency-weighted sleep-debt estimate across the latest 14 usable nights, with + /// raw per-night deltas for context. Computed once per data change. let sleepDebtLedger: SleepDebtLedger } @@ -365,7 +399,11 @@ extension SleepModel { // `BodyVitalSigns.logicalDayKey` rather than `Repository.logicalDayKey`: same 04:00 boundary, // but self-contained, so this stays pure and independent of the @MainActor Repository. let fresh = Baselines.freshestCarried(points, todayKey: BodyVitalSigns.logicalDayKey(now)) - return (fresh?.value, mean(series), series) + // #1946: track the day the carried value came from so the tile can stamp it. nil when there is + // no carried value, or when the carried value IS today's own (no stamp needed for today's read). + let todayKey = BodyVitalSigns.logicalDayKey(now) + let latestDay = (fresh != nil && fresh!.day != todayKey) ? fresh!.day : nil + return (fresh?.value, latestDay, mean(series), series) } /// Sleep performance %: the imported WHOOP figure when the export carried one for that day; @@ -392,7 +430,7 @@ extension SleepModel { let imported = importedSleep if let lastDay = days.last?.day, imported[lastDay]?.consistencyPct != nil { let series = days.compactMap { imported[$0.day]?.consistencyPct } - return (series.last, mean(series), series) + return (series.last, nil, mean(series), series) } let cal = Calendar.current func bedMinutes(_ s: CachedSleepSession) -> Double { @@ -403,7 +441,7 @@ extension SleepModel { return m } let mins = sleeps.map(bedMinutes) - guard mins.count >= 3 else { return (nil, nil, []) } + guard mins.count >= 3 else { return (nil, nil, nil, []) } var scores: [Double] = [] for i in mins.indices { let lo = Swift.max(0, i - 13) @@ -414,7 +452,7 @@ extension SleepModel { let sd = variance.squareRoot() scores.append(Swift.max(0, Swift.min(100, 100 * (1 - sd / 120)))) } - return (scores.last, mean(scores), scores) + return (scores.last, nil, mean(scores), scores) } /// Hours vs needed % = asleep / need. The imported sleep_need_min wins per day; else the @@ -443,20 +481,23 @@ extension SleepModel { metric(days: days) { $0.respRateBpm } } - /// Sleep debt (minutes): the imported sleep_debt_min when the export carried it; else the - /// APPROXIMATE per-night need − (main sleep + nap sleep), floored at 0. + /// Sleep debt (minutes): imported `sleep_debt_min` remains export-verbatim. Otherwise use + /// the same recency-weighted ledger as the card, including nap credit and its 14-night window. static func sleepDebtSeries(days: [DailyMetric], importedSleep: [String: ImportedSleepFigures], napSleepMinByDay: [String: Double]) -> Metric { - let imported = importedSleep let need = debtNeedMin(days: days) // #242: normative need, not the self-referential mean - let series = days.compactMap { d -> Double? in - if let debt = imported[d.day]?.debtMin { return debt } // minutes, export-verbatim - guard let asleep = SleepDebt.creditedSleepMin( + let credited = days.map { d in + (day: d.day, totalSleepMin: SleepDebt.creditedSleepMin( mainSleepMin: d.totalSleepMin, - napSleepMin: napSleepMinByDay[d.day] ?? 0), need > 0 else { return nil } - return Swift.max(0, need - asleep) // APPROXIMATE fallback + napSleepMin: napSleepMinByDay[d.day] ?? 0)) } - return (series.last, mean(series), series) + let importedDebt = importedSleep.compactMapValues(\.debtMin) + let series = SleepDebt.debtSeries( + series: credited, + needHours: need / 60.0, + importedDebtMin: importedDebt + ).map(\.value) + return (series.last, nil, mean(series), series) } // MARK: Trend points @@ -469,10 +510,9 @@ extension SleepModel { // MARK: Sleep-debt ledger - /// The rolling 14-night sleep-debt ledger from the cached daily metrics. Measures against the - /// normative `debtNeedMin` (the engine's `personalizedNeedHours`) — the SAME need the per-night - /// "Sleep Debt" tile uses, so the running-balance card and the tile agree — over each main night's - /// `totalSleepMin` plus actual asleep minutes from separately-recorded naps. (#242) + /// The 14-night recency-weighted estimate from cached daily metrics. It measures against the + /// normative `debtNeedMin` (the engine's `personalizedNeedHours`) — the SAME need and recurrence + /// the local "Sleep Debt" tile uses — and credits actual asleep minutes from separate naps. (#242) static func debtLedger(days: [DailyMetric], napSleepMinByDay: [String: Double]) -> SleepDebtLedger { SleepDebt.ledger( series: days.map { day in diff --git a/Strand/Screens/SleepView.swift b/Strand/Screens/SleepView.swift index 32ff136edd..135de4c823 100644 --- a/Strand/Screens/SleepView.swift +++ b/Strand/Screens/SleepView.swift @@ -28,6 +28,9 @@ import UIKit struct SleepView: View { @EnvironmentObject var repo: Repository + /// For `circadianPhase` only (the body-clock dial). Named `appModel` because `model` on this screen is + /// already the built `SleepModel`. + @EnvironmentObject var appModel: AppModel // NOTE: SleepView itself deliberately does NOT observe `LiveState`. A connected strap publishes // at ~1 Hz; observing here would re-evaluate this heavy body on every tick. The only two live // dependencies — the "going to sleep / awake" mark card (it appends to the strap log) and the @@ -72,7 +75,7 @@ struct SleepView: View { @State private var habitualMidsleepSec: Int? = nil /// Persisted per-epoch MOTION series keyed by each session's detected `startTs` (#407). Loaded in the - /// same `.task` as `allSessions` from `repo.sessionMotions(starts:)`, then laid along the hypnogram for + /// same `.task` as `allSessions` from `repo.sessionMotions(sessions:)`, then laid along the hypnogram for /// the SAME main-night GROUP blocks the hero resolved (mergeDay's group) — we do NOT re-resolve the /// night, only read the already-chosen group's stored motion. A block with no stored series stays absent /// (honest empty state for older rows whose `motionJSON` is NULL). Refreshed with `allSessions`. @@ -151,6 +154,7 @@ struct SleepView: View { // Each top-level section fades + rises in sequence on first appear (Reduce-Motion safe). VStack(alignment: .leading, spacing: NoopMetrics.sectionSpacing) { if let sleepUndo { sleepUndoBanner(sleepUndo) } + SleepFreshnessNote(latestWakeTs: resolved.night.session.endTs) // Bleed past ScreenScaffold's 16/24 gutters so the hero column is edge-to-edge // in the upper band; the night scene itself is the fixed topBackground. // Customize sits at the end of the hero (not floating in a blank band). @@ -205,7 +209,7 @@ struct SleepView: View { habitualMidsleepSec = await repo.habitualMidsleepSec() // Per-epoch motion for every block (#407), keyed by detected start. mergeDay reads only the // already-resolved group's entries — this just pre-fetches them all so the model build is sync. - motionByStart = await repo.sessionMotions(starts: allSessions.map { $0.startTs }) + motionByStart = await repo.sessionMotions(sessions: allSessions) nightOffset = 0 navNight = nil modelKey = dataKey @@ -306,7 +310,8 @@ struct SleepView: View { /// Locale-formatted clock time (no date) for the banner's window range. private func clockTime(_ ts: Int) -> String { Date(timeIntervalSince1970: TimeInterval(ts)) - .formatted(date: .omitted, time: .shortened) + .formatted(Date.FormatStyle(date: .omitted, time: .shortened) + .locale(AppClock.formattingLocale)) // #1821 } /// The transient undo strip: a Rest-tinted frosted banner with the suppressed window and a real Undo @@ -356,22 +361,25 @@ struct SleepView: View { return n == 0 ? "Last night" : (n == 1 ? "1 night ago" : "\(n) nights ago") } - /// #1311: how many CALENDAR nights back the carousel night at `offset` is from the newest recorded - /// night. The ◀/▶ carousel steps by RECORDED night (`navDays`, newest-first), so a night with no - /// data (strap off-body) is a gap the flat index can't see — labelling by index makes two nights - /// either side of a skipped night read as consecutive and desyncs the "N nights ago" labels (and the - /// Rest value they name). Uses the same local start-of-day `navDays` is grouped by; falls back to the - /// raw index if it can't resolve. 0 = last night. Mirrors Android SleepHeroLogic.calendarNightsAgo. - private func nightsAgo(_ offset: Int) -> Int { - let days = navDays - guard offset >= 0, offset < days.count, - let newestTs = days.first?.first?.endTs, let shownTs = days[offset].first?.endTs - else { return offset } - let cal = Calendar.current - let shown = cal.startOfDay(for: Date(timeIntervalSince1970: TimeInterval(shownTs))) - let newest = cal.startOfDay(for: Date(timeIntervalSince1970: TimeInterval(newestTs))) - let d = cal.dateComponents([.day], from: shown, to: newest).day ?? offset - return d >= 0 ? d : offset + /// #1311: how many nights back the carousel night at `offset` is, counted in CALENDAR nights rather + /// than carousel index. The ◀/▶ carousel steps by RECORDED night (`navDays`, newest-first), so a + /// night with no data (strap off-body) is a gap the flat index can't see — labelling by index makes + /// two nights either side of a skipped night read as consecutive and desyncs the "N nights ago" + /// labels (and the Rest value they name). + /// + /// Counted FROM TODAY, not from the newest recorded night. + /// + /// Delegates to `SleepNightLabel.nightsAgo`, which is where this logic is tested. It lived inline + /// and private here, which is why the newest-anchored defect went uncaught on this platform. + /// Kotlin twin: `calendarNightsAgo`. + private func nightsAgo(_ offset: Int, now: Date = Date()) -> Int { + SleepNightLabel.nightsAgo( + // Optional per entry, NOT `?? 0`: a day group with no session must fall back to the offset + // the way the Kotlin twin does. Zero would be 1970 and would read as ~20,000 nights ago. + wakeTimestamps: navDays.map { $0.first.map { s in Int(s.endTs) } }, + offset: offset, + today: Repository.logicalDay(now) + ) } /// The night the Rest hero reflects: the ◀/▶-navigated night while browsing (falling back to @@ -401,6 +409,7 @@ struct SleepView: View { switch section { case .sleepMarks: SleepMarkCard() case .stages: hero(model) + case .bodyClock: bodyClockDial(model) case .nightDetail: NightDetailCard(model: model) case .sleepDebt: SleepDebtLedgerCard(model: model) case .stagesVsTypical: StagesVsTypicalCard(model: model) @@ -408,6 +417,28 @@ struct SleepView: View { } } + /// The 24 h dial (#1680), or nothing at all. + /// + /// Drawn only for a fit that is at least `.wide`: an `.unreadable` rhythm has no phase to compare a + /// night against, and an empty ring would read as a broken chart rather than as "not enough data". The + /// card is a reorderable Sleep section, so anyone who does not want it hides it in Arrange — the same + /// affordance every other card on this screen already has, rather than a new setting of its own. + @ViewBuilder + private func bodyClockDial(_ model: SleepModel) -> some View { + if let phase = appModel.circadianPhase, phase.confidence != .unreadable { + BodyClockDialCard(estimate: phase, + actualBedHour: Self.localClockHour(model.night.session.effectiveStartTs), + actualWakeHour: Self.localClockHour(model.night.session.endTs)) + } + } + + /// A unix second as a fractional local clock hour — the dial's only input beyond the phase estimate. + static func localClockHour(_ ts: Int) -> Double { + let c = Calendar.current.dateComponents([.hour, .minute], + from: Date(timeIntervalSince1970: TimeInterval(ts))) + return Double(c.hour ?? 0) + Double(c.minute ?? 0) / 60.0 + } + /// The compact "Customize" affordance above the arrangeable cards — opens the Arrange sheet. Mirrors /// the Today tab's arrange entry and the Android Sleep affordance. private var sleepArrangeAffordance: some View { @@ -793,6 +824,14 @@ struct SleepView: View { if stageStagingIsSparse(night) { stageIncompleteNote } + // #1716 — a device-provided hypnogram assembled from records that never all arrived leaves a + // HOLE in the timeline while the session still spans the whole night, so a night we saw a + // fraction of renders as a complete one. Say which fraction. This is the only place the + // coverage guard becomes visible: the engine's matching Rest downgrade lands in a transient + // `DayResult` field no screen reads, so the gate was otherwise correct and inert. + if let coverage = stageCoverage(night), coverage < HypnogramCoverage.minCoverage { + stagePartialNote(coverage) + } // For an Oura-provided night, say plainly that this split is the ring's RAW on-device // classification — so the larger Awake / smaller Deep+REM here isn't misread as the polished // numbers the Oura app shows for the same night (the app post-processes the same stream). @@ -918,6 +957,17 @@ struct SleepView: View { night.sourceBlocks.contains { $0.stagingSparse == true } } + /// How much of this night's window its stage timeline actually accounts for, or nil when coverage is + /// not a measurable question for the payloads it was built from (#1716). Asked of the bridged main-night + /// GROUP via the SAME shared accumulation `analyzeDay` uses, threading the same learned habitual so the + /// group resolves identically to the hero's — a per-row answer would be the wrong question for a + /// fragmented night. Mirror in Kotlin. + private func stageCoverage(_ night: Night) -> Double? { + let group = SleepView.mainNightGroup(night.sourceBlocks, + habitualMidsleepSec: night.habitualMidsleepSec) + return HypnogramCoverage.groupFraction(group.isEmpty ? night.sourceBlocks : group) + } + /// Pure H9 gate (unit-testable without a live view) — true when a night's staging is low-confidence: /// a high-efficiency night whose deep+REM share is below the restorative floor. Built on the engine's /// own `ScoreConfidence.rest(...)` so the UI flag and the persisted Rest confidence agree. `asleepMin`, @@ -977,6 +1027,35 @@ struct SleepView: View { .accessibilityElement(children: .combine) } + /// The PARTIAL-TIMELINE caveat (#1716): this night's stage segments account for less than + /// `HypnogramCoverage.minCoverage` of the window the session claims, so the stage totals describe only + /// the part of the night the timeline accounts for. Distinct from BOTH notes above — H9 doubts the + /// deep/REM SPLIT of a fully-described night, #345 doubts a night staged on thin motion, and this one + /// says plainly that some of the night is MISSING rather than doubted. It is the visible half of the + /// engine-side guard: `analyzeDay` already downgrades Rest to `.building` on exactly this condition, + /// but that tier is transient engine output no screen reads, so without this the gate was inert. + /// + /// HONEST-DATA: it reports only what was observed and changes no number. The percentage is floored, + /// never rounded — 94.8% must not print as "95%" and appear to contradict the gate that flagged it. + /// The copy names NO cause and offers NO remedy, deliberately: on the 08-29/30 and 08-30/31 captures the + /// missing codes DID reach NOOP — the ring reported them unwritten (0xFF), the persist log trimmed + /// exactly as many as the hole is wide — and re-persisting the same night 5 and 8 times left the hole + /// intact. "The rest never reached NOOP" and "syncing again can fill in" were both wrong. Nor does the + /// copy point at the totals by DIRECTION: both hosts render this note below the stage-breakdown card + /// that carries them, so "the totals below" pointed the wrong way on every screen that shipped it. + private func stagePartialNote(_ coverage: Double) -> some View { + let pct = Int((coverage * 100).rounded(.down)) + return HStack(alignment: .top, spacing: 8) { + SourceBadge("Partly recorded", tint: StrandPalette.statusWarning) + Text("Only \(pct)% of this night's window has stage data. The stage totals cover only that part of the night.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 2) + .accessibilityElement(children: .combine) + } + /// Honest caveat for an Oura-provided night: the stage split shown here is the ring's RAW on-device /// SleepNet classification, read straight off the BLE phase stream — NOT the adjusted stages the Oura /// app displays. The app post-processes the same night, so its Deep/REM run higher and its Awake lower; @@ -1265,9 +1344,9 @@ struct SleepView: View { // MARK: - WHOOP stage-timeline rows (the sleep-details reference design, ryanAtriumAi #988) /// Clock labels for the timeline axis; "jmm" respects the device 12/24-hour setting. - private static let stageAxisFormatter: DateFormatter = { - let f = DateFormatter(); f.locale = AppLanguage.activeLocale; f.setLocalizedDateFormatFromTemplate("jmm"); return f - }() + /// #1821: routed through AppClock so the Clock format setting reaches this label. Was a `static + /// let`, which would have frozen the reader's choice at first use until the app relaunched. + private static var stageAxisFormatter: DateFormatter { AppClock.hourMinuteFormatter() } /// The WHOOP sleep-stages chart: a stack of four per-stage timeline rows (AWAKE · LIGHT · /// DEEP · REM, WHOOP's order) over a shared onset→wake time axis. Each row is independently @@ -1976,10 +2055,7 @@ struct SleepView: View { @ViewBuilder private var emptyState: some View { - // While the strap is mid-offload, say so — "No nights" reads as final otherwise (#77). The note - // owns the `LiveState` observation in its own leaf so the chunk count ticks without re-rendering - // SleepView (scroll-stutter isolation; identical output to the prior inline check). - SleepSyncingNote() + SleepFreshnessNote(latestWakeTs: nil) if repo.loaded { ComingSoon(what: "No nights here yet. Import your WHOOP export in Data Sources to see every night, your sleep stages and trends straight away. Or open Intelligence to see last night computed from the strap after you wear it to bed.") } else { @@ -2482,10 +2558,72 @@ struct SleepMarkCard: View { /// The "Syncing strap history…" note, shown only while a historical offload is running (#77). Owns the /// `LiveState` observation so the chunk count ticks without re-rendering the rest of the Sleep screen. -private struct SleepSyncingNote: View { +enum SleepFreshnessStatus: Equatable { + case syncing, calculating, syncFailed, awaitingSync, notDetected +} + +/// Pure priority ladder behind the Sleep status banner. "Missing" is deliberately held until morning so +/// opening Sleep during the night does not claim a still-in-progress night was missed. +func resolveSleepFreshness(hasCurrentNight: Bool, morningReady: Bool, syncing: Bool, + calculating: Bool, syncedSinceDayStart: Bool, + syncFailed: Bool) -> SleepFreshnessStatus? { + if syncing { return .syncing } + if calculating { return .calculating } + if hasCurrentNight || !morningReady { return nil } + if syncFailed { return .syncFailed } + return syncedSinceDayStart ? .notDetected : .awaitingSync +} + +/// Explicit state for the expected current night. Older sleep can remain available underneath, but it is +/// never left to impersonate today's result while a sync, calculation, or failed detection is unresolved. +private struct SleepFreshnessNote: View { @EnvironmentObject private var live: LiveState + @EnvironmentObject private var intelligence: IntelligenceEngine + let latestWakeTs: Int? + var body: some View { - if live.backfilling { SyncingHistoryNote(chunks: live.syncChunksThisSession) } + let calendar = Calendar.current + let now = Date() + let start = calendar.startOfDay(for: now) + let current = latestWakeTs.map { + calendar.isDate(Date(timeIntervalSince1970: TimeInterval($0)), inSameDayAs: now) + } ?? false + // AppModel intentionally waits two quiet seconds after HISTORY_COMPLETE before starting the + // scoring pass. Treat that debounce as calculation too; otherwise the banner can flash the final + // "wasn't detected" verdict between sync completion and `intelligence.computing` becoming true. + let calculationQueued = live.lastSyncedAt.map { + (0..<5).contains(now.timeIntervalSince1970 - $0) + } ?? false + let status = resolveSleepFreshness( + hasCurrentNight: current, + morningReady: calendar.component(.hour, from: now) >= 6, + syncing: live.backfilling, + calculating: intelligence.computing || calculationQueued, + syncedSinceDayStart: (live.lastSyncedAt ?? 0) >= start.timeIntervalSince1970, + syncFailed: live.lastSyncError != nil + ) + switch status { + case .syncing: + SyncingHistoryNote(chunks: live.syncChunksThisSession) + case .calculating: + DataPendingNote(title: "Calculating last night's sleep…", + message: "Your strap history is in. NOOP is detecting and staging the night now.", + symbol: "waveform.path.ecg") + case .syncFailed: + DataPendingNote(title: "Last night's sleep hasn't synced", + message: "The history sync stopped before it finished. Keep the strap nearby and try Sync again.", + symbol: "exclamationmark.arrow.triangle.2.circlepath") + case .awaitingSync: + DataPendingNote(title: "Waiting for last night's sleep", + message: "Connect the strap and sync its history. NOOP will calculate the night when the overnight data arrives.", + symbol: "arrow.triangle.2.circlepath") + case .notDetected: + DataPendingNote(title: "Last night's sleep wasn't detected", + message: "Sync finished, but NOOP couldn't confidently identify a sleep window. Keep the strap connected and try Sync again; the older night below is still your latest detected sleep.", + symbol: "moon.zzz") + case nil: + EmptyView() + } } } diff --git a/Strand/Screens/SmartAlarmView.swift b/Strand/Screens/SmartAlarmView.swift index f69c181abf..86ab5ab6c4 100644 --- a/Strand/Screens/SmartAlarmView.swift +++ b/Strand/Screens/SmartAlarmView.swift @@ -242,6 +242,27 @@ struct SmartAlarmView: View { .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) .frame(maxWidth: .infinity, alignment: .leading) + // #1706: ask the strap what it actually has stored. The readback was otherwise + // only reachable by ARMING, so anyone whose alarm is off could not produce the + // frame that explains a wrong reported time. + // + // Shown unconditionally, where the Android twin hides it until bonded. That is a + // deliberate platform difference, not drift: the Android card already observes + // LiveState, so gating there is free, while this view does not — and pulling + // `live` in would re-render the whole alarm screen on every 1 Hz HR tick, which + // this codebase keeps parent views clear of on purpose. `getStrapAlarm` no-ops + // and logs when nothing is connected, so the worst case is one ignored write. + Divider().overlay(StrandPalette.hairline) + Button { + model.ble.getStrapAlarm() + } label: { + Text("Check what the strap has stored") + } + .buttonStyle(NoopButtonStyle(.secondary, fullWidth: true)) + Text("The answer from the strap appears in your strap log and debug export, with the raw bytes it replied with.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .frame(maxWidth: .infinity, alignment: .leading) } } } @@ -345,6 +366,9 @@ struct SmartAlarmView: View { if !on { for weekday in 1...7 { WindDownNudge.setWakeOverride(weekday: weekday, minutes: nil) } overrides = [:] + // #1864: re-arm the strap alarm + backup notification so they drop the per-day + // times and revert to the single default, matching the wind-down nudge's revert. + model.applySmartAlarm() } } } @@ -375,6 +399,9 @@ struct SmartAlarmView: View { Button { WindDownNudge.setWakeOverride(weekday: weekday, minutes: nil) overrides[weekday] = nil + // #1864: re-arm so clearing an override reverts that day's wake to the default time + // on the strap alarm + backup notification too, not just the wind-down nudge. + model.applySmartAlarm() } label: { Image(systemName: "arrow.uturn.backward") .font(.system(size: 12, weight: .semibold)) @@ -407,6 +434,10 @@ struct SmartAlarmView: View { let m = (c.hour ?? 7) * 60 + (c.minute ?? 0) WindDownNudge.setWakeOverride(weekday: weekday, minutes: m) overrides[weekday] = m + // #1864: re-arm the strap alarm + backup notification so the new per-day time takes + // effect immediately, not just the wind-down reminder. Without this the override moved + // only the evening nudge and left the wake on the default time. + model.applySmartAlarm() } ) } diff --git a/Strand/Screens/StagesCard.swift b/Strand/Screens/StagesCard.swift index 8c50f77a91..f22b284cbb 100644 --- a/Strand/Screens/StagesCard.swift +++ b/Strand/Screens/StagesCard.swift @@ -123,6 +123,12 @@ struct StageDetailView: View { if stageStagingIsSparse(night) { stageIncompleteNote } + // #1716 — a device-provided hypnogram whose records never all arrived leaves a HOLE in the + // timeline while the session still spans the whole night, so a night we saw a fraction of + // renders as a complete one. Say which fraction, exactly as the Sleep tab does. + if let coverage = stageCoverage(night), coverage < HypnogramCoverage.minCoverage { + stagePartialNote(coverage) + } // For an Oura-provided night, say plainly that this split is the ring's RAW on-device // classification — so the larger Awake / smaller Deep+REM here isn't misread as the polished // numbers the Oura app shows for the same night (the app post-processes the same stream). @@ -246,6 +252,16 @@ struct StageDetailView: View { night.sourceBlocks.contains { $0.stagingSparse == true } } + /// How much of this night's window its stage timeline actually accounts for, or nil when coverage is not + /// a measurable question for the payloads it was built from (#1716). Same shared group accumulation as + /// `SleepView.stageCoverage(_:)` — this host renders the same night and must not reach a different + /// verdict about it. Mirror in Kotlin. + private func stageCoverage(_ night: Night) -> Double? { + let group = SleepView.mainNightGroup(night.sourceBlocks, + habitualMidsleepSec: night.habitualMidsleepSec) + return HypnogramCoverage.groupFraction(group.isEmpty ? night.sourceBlocks : group) + } + /// The H9 low-confidence note shown beneath the stage breakdown — a warning-tinted badge plus a /// one-line honest explanation. No faked stages, no tanked score; just a clear "treat this split with /// care" so a user doesn't read a likely staging miss as a real deep/REM drought. (#H9) @@ -278,6 +294,22 @@ struct StageDetailView: View { .accessibilityElement(children: .combine) } + /// The PARTIAL-TIMELINE caveat (#1716) — twin of `SleepView.stagePartialNote(_:)`, same copy and same + /// floored percentage. Says that part of the night is MISSING, which is a different claim from the two + /// notes above (a doubted split, and a night staged on thin motion). Changes no number. + private func stagePartialNote(_ coverage: Double) -> some View { + let pct = Int((coverage * 100).rounded(.down)) + return HStack(alignment: .top, spacing: 8) { + SourceBadge("Partly recorded", tint: StrandPalette.statusWarning) + Text("Only \(pct)% of this night's window has stage data. The stage totals cover only that part of the night.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 2) + .accessibilityElement(children: .combine) + } + /// Honest caveat for an Oura-provided night: the stage split shown here is the ring's RAW on-device /// SleepNet classification, read straight off the BLE phase stream — NOT the adjusted stages the Oura /// app displays. The app post-processes the same night, so its Deep/REM run higher and its Awake lower; @@ -421,9 +453,9 @@ struct StageDetailView: View { } /// Clock labels for the timeline axis; "jmm" respects the device 12/24-hour setting. - private static let stageAxisFormatter: DateFormatter = { - let f = DateFormatter(); f.locale = AppLanguage.activeLocale; f.setLocalizedDateFormatFromTemplate("jmm"); return f - }() + /// #1821: routed through AppClock so the Clock format setting reaches this label. Was a `static + /// let`, which would have frozen the reader's choice at first use until the app relaunched. + private static var stageAxisFormatter: DateFormatter { AppClock.hourMinuteFormatter() } /// The WHOOP sleep-stages chart: a stack of four per-stage timeline rows (AWAKE · LIGHT · /// DEEP · REM, WHOOP's order) over a shared onset→wake time axis. Each row is independently diff --git a/Strand/Screens/StressView.swift b/Strand/Screens/StressView.swift index 88413ec444..803abbbf41 100644 --- a/Strand/Screens/StressView.swift +++ b/Strand/Screens/StressView.swift @@ -113,13 +113,11 @@ struct StressView: View { freqHRV = nil return } - let rr = (try? await repo.storeHandle()?.rrIntervals( - deviceId: repo.deviceId, from: from, to: to, limit: 200_000)) ?? [] + let rr = await repo.rrIntervals(from: from, to: to, limit: 200_000) // Wrist accelerometer for the motion gate: an ambulatory hour is EXERTION, not stress, so it // is masked rather than scored (DaytimeStress). Same store read as R-R; empty on hardware or // imports with no gravity, which is exactly the "no masking, prior behaviour" degradation. - let gravity = (try? await repo.storeHandle()?.gravitySamples( - deviceId: repo.deviceId, from: from, to: to, limit: 200_000)) ?? [] + let gravity = await repo.gravitySamplesUnion(from: from, to: to, limit: 200_000) // Score today's hours against the PERSONAL cross-day daytime baseline ONLY when the user has // opted in (Settings → Experimental) AND enough worn history exists (Oura-style @@ -173,8 +171,7 @@ struct StressView: View { let dayTz = TimeZone.current.secondsFromGMT(for: dayStart) let dayHR = await repo.hrSamples(from: from, to: to, limit: 200_000) guard !dayHR.isEmpty else { continue } // unworn day — no floor to learn, skip the R-R read - let dayRR = (try? await repo.storeHandle()?.rrIntervals( - deviceId: repo.deviceId, from: from, to: to, limit: 200_000)) ?? [] + let dayRR = await repo.rrIntervals(from: from, to: to, limit: 200_000) days.append(.init(hr: dayHR, rr: dayRR, tzOffsetSeconds: dayTz)) } return DaytimeStress.scoringMode(history: days) diff --git a/Strand/Screens/TestCentreView.swift b/Strand/Screens/TestCentreView.swift index ec5a195605..8699f38446 100644 --- a/Strand/Screens/TestCentreView.swift +++ b/Strand/Screens/TestCentreView.swift @@ -4,6 +4,7 @@ import StrandAnalytics import StrandImport import PolarProtocol import WhoopStore +import WhoopProtocol /// Settings -> Test Centre. The single home for every diagnostic, log and test control (spec section 7). /// @@ -29,6 +30,11 @@ struct TestCentreView: View { @State private var infoMessage = "" @State private var showInfo = false + // #1853: skin-temp absolute backfill (on-demand, diagnostic-first). Runs the walker that fills + // `skinTempC` for nights outside the 21-night rescore window. In-progress flag + last result line. + @State private var skinTempBackfillRunning = false + @State private var skinTempBackfillStatus: String? + // Section 3: scheduled daily auto-export, the same ScheduledDebugExport store the Settings card uses. @State private var debugExportOn = ScheduledDebugExport.isEnabled @State private var debugExportMinutes = ScheduledDebugExport.timeMinutes @@ -36,6 +42,14 @@ struct TestCentreView: View { @State private var debugExportKeep = ScheduledDebugExport.keepCount @State private var showClearExportsConfirm = false + // WHOOP 5/MG developer controls. These use the same persisted keys as the former Settings card; + // moving the UI does not reset an opt-in or lose the ability to undo a persistent strap write. + @AppStorage(PuffinExperiment.defaultsKey) private var puffinExperiments = false + @AppStorage(PuffinFrameRecorder.enabledKey) private var puffinCapture = false + @AppStorage(PuffinExperiment.deepDataKey) private var deepDataEnabled = false + @AppStorage(PuffinExperiment.broadcastHrKey) private var broadcastHrEnabled = false + @AppStorage(PuffinExperiment.ecgRawDataKey) private var ecgRawDataEnabled = false + /// The strap model the user last picked, the same key SettingsView's showFiveMGControls gate reads. @AppStorage("selectedWhoopModel") private var selectedWhoopModelRaw = WhoopModel.whoop4.rawValue @@ -77,6 +91,8 @@ struct TestCentreView: View { VStack(alignment: .leading, spacing: NoopMetrics.sectionSpacing) { domainModesCard.staggeredAppear(index: 0) diagnosticToolsCard.staggeredAppear(index: 1) + if is5MG { rawDataCollectorCard.staggeredAppear(index: 2) } + if is5MG { fiveMGProtocolDiagnosticsCard.staggeredAppear(index: 3) } exportCard.staggeredAppear(index: 2) experimentalAlgorithmsCard.staggeredAppear(index: 3) } @@ -132,6 +148,100 @@ struct TestCentreView: View { // MARK: - Section 2: Diagnostic tools (strap log + recalibrate + env dump) + @ViewBuilder private var rawDataCollectorCard: some View { + NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.space3) { + Text("5/MG RAW DATA COLLECTOR") + .font(StrandFont.overline).tracking(StrandFont.overlineTracking) + .foregroundStyle(StrandPalette.textSecondary) + Text("Record, review, export, and delete bounded 100 Hz motion sessions. Normal sync is unchanged.") + .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + NavigationLink { + RawDataCollectorView() + } label: { + Label("Open raw-data collector", systemImage: "waveform.path.ecg") + } + .buttonStyle(NoopButtonStyle(.primary, fullWidth: true)) + Text(live.connected ? "WHOOP 5/MG connected." : "Connect your WHOOP 5/MG to start a raw-data session.") + .font(StrandFont.caption) + .foregroundStyle(live.connected ? StrandPalette.textSecondary : StrandPalette.statusWarning) + } + } + } + + @ViewBuilder private var fiveMGProtocolDiagnosticsCard: some View { + NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.space3) { + Text("5/MG PROTOCOL DIAGNOSTICS") + .font(StrandFont.overline).tracking(StrandFont.overlineTracking) + .foregroundStyle(StrandPalette.textSecondary) + Text("Developer tools for unmapped protocol features. None of these are required for normal WHOOP 5/MG recording, history sync, or the bounded Raw Data Collector.") + .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + + Toggle("Protocol probes", isOn: $puffinExperiments) + .toggleStyle(.switch).tint(StrandPalette.accent) + Text("Sends experimental protocol queries and records the replies in the strap log.") + .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) + + Divider().overlay(StrandPalette.hairline) + Toggle("Broadcast heart rate from the strap", isOn: $broadcastHrEnabled) + .toggleStyle(.switch).tint(StrandPalette.accent) + .onChangeCompat(of: broadcastHrEnabled) { model.ble.setBroadcastHr($0) } + Text("Writes the reversible 5/MG advertising flag for Garmin, Zwift, and compatible gym equipment.") + .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) + + Divider().overlay(StrandPalette.hairline) + Toggle("Legacy R22 feature-flag experiment", isOn: $deepDataEnabled) + .toggleStyle(.switch).tint(StrandPalette.accent) + Text("The strap accepts these writes, but NOOP has not observed them enabling a separate live stream. This is not the Raw Data Collector.") + .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) + if deepDataEnabled { + NoopButton("Send legacy R22 enable sequence", systemImage: "bolt.badge.automatic", kind: .secondary) { + model.ble.enableWhoop5DeepData() + } + .disabled(!live.encryptedBond || !live.worn) + } + NoopButton("Clear legacy R22 flags on strap", systemImage: "bolt.slash", kind: .secondary) { + model.ble.disableWhoop5DeepData() + } + .disabled(!live.encryptedBond || live.r22DisableReport == BLEManager.deviceConfigProbeWaiting) + if let result = live.r22DisableReport { + Text(result).font(StrandFont.caption).foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + + Divider().overlay(StrandPalette.hairline) + Toggle("WHOOP MG ECG raw-data gate", isOn: $ecgRawDataEnabled) + .toggleStyle(.switch).tint(StrandPalette.accent) + Text("MG-only protocol instrumentation, not a medical ECG feature.") + .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) + if ecgRawDataEnabled { + HStack(spacing: NoopMetrics.space3) { + NoopButton("Gate on", systemImage: "waveform.path.ecg", kind: .secondary) { + model.ble.setEcgRawDataGate(true) + } + NoopButton("Gate off", systemImage: "arrow.uturn.backward", kind: .secondary) { + model.ble.setEcgRawDataGate(false) + } + } + .disabled(!live.encryptedBond || live.whoop5Variant != Whoop5Variant.mg.label) + if let result = live.ecgRawDataGate { + Text(result.summary).font(StrandFont.caption).foregroundStyle(StrandPalette.textSecondary) + } + } + + Divider().overlay(StrandPalette.hairline) + Toggle("Passive history/protocol trace", isOn: $puffinCapture) + .toggleStyle(.switch).tint(StrandPalette.accent) + Text("Records frames that already arrive. It does not start sensors and can create large files. Use the export section below to save the trace with its strap log.") + .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + @ViewBuilder private var diagnosticToolsCard: some View { NoopCard { VStack(alignment: .leading, spacing: NoopMetrics.space3) { @@ -172,6 +282,25 @@ struct TestCentreView: View { Divider().overlay(StrandPalette.hairline) + // #1853: skin-temp absolute backfill (on-demand, diagnostic-first). Fills `skinTempC` + // for nights outside the 21-night rescore window that never got an absolute. Fill-only: + // it can only fill a NULL, never overwrite a measured value or touch the deviation. + NoopButton("Backfill skin-temp absolutes", systemImage: "thermometer.medium", + kind: .secondary) { + runSkinTempBackfill() + } + .disabled(skinTempBackfillRunning) + Text("Re-derives the absolute °C for nights the 21-night rescore window never reaches, using the same funnel as the scoring pass. Fill-only: it never overwrites a measured value. Watch the strap log for the result.") + .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + if let status = skinTempBackfillStatus { + Text(status) + .font(StrandFont.caption).foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + + Divider().overlay(StrandPalette.hairline) + // Environment dump: the IOSDiagnostics-backed block exportableLogText already carries, // surfaced as a copyable readout (spec section 3.4). NoopButton("Copy environment dump", systemImage: "info.circle", kind: .secondary) { @@ -417,6 +546,37 @@ struct TestCentreView: View { showInfo = true } + /// #1853: run the skin-temp absolute backfill on demand (diagnostic-first trigger). The walker + /// resolves the WHOOP 4.0 window anchor from the current scoring window, pages through the + /// candidate nights, and fills each NULL `skinTempC`. The result is logged to the strap log and + /// shown under the button so the user sees exactly what filled, declined, and had no raw data. + private func runSkinTempBackfill() { + guard !skinTempBackfillRunning else { return } + skinTempBackfillRunning = true + skinTempBackfillStatus = String(localized: "Backfilling…") + Task { @MainActor in + guard let store = await model.repo.storeHandle() else { + skinTempBackfillStatus = String(localized: "No on-device store yet.") + skinTempBackfillRunning = false + return + } + let walker = SkinTempBackfillWalker(store: store) + let result = await walker.runBackfill() + let anchorNote = result.windowAnchorRaw != nil + ? "anchor from current window" + : "no 4.0 anchor (5/MG or too few worn samples)" + let endNote = result.reachedEnd ? "reached end" : "hit page budget" + let line = "skin-temp backfill (#1853): filled \(result.filledCount), " + + "declined \(result.declinedCount), no raw data \(result.noRawDataCount) " + + "(\(anchorNote), \(endNote))" + live.append(log: line) + skinTempBackfillStatus = line + skinTempBackfillRunning = false + // Refresh so the newly-filled absolutes show in the charts/explorer. + await model.repo.refresh() + } + } + private var debugExportTimeBinding: Binding { Binding( get: { @@ -643,7 +803,8 @@ private struct ConnectionReadoutPanel: View { // LiveState field FrameRouter writes. let deviceClock = ConnectionReadout.clockCorrelatedDevice(logLines: live.log) let rtcWarning = ConnectionReadout.rtcWarning(deviceClockUnix: deviceClock, - strapNewestUnix: live.strapRange?.newestUnix) + strapNewestUnix: live.strapRange?.newestUnix, + batteryPct: live.batterySamples.last?.soc) VStack(alignment: .leading, spacing: 4) { ReadoutRow(label: String(localized: "Connection uptime"), value: uptime) ReadoutRow(label: String(localized: "Reconnects this run"), value: String(reconnects)) diff --git a/Strand/Screens/TodayCustomizationMetadata.swift b/Strand/Screens/TodayCustomizationMetadata.swift index 5f9ca15cc0..8e6d9f6775 100644 --- a/Strand/Screens/TodayCustomizationMetadata.swift +++ b/Strand/Screens/TodayCustomizationMetadata.swift @@ -48,6 +48,8 @@ extension KeyMetric { case .steps: return "figure.walk" case .weight: return "scalemass" case .calories: return "flame.fill" + // Same glyph the sibling "Your Cards" tile (`DashboardCard.skinTemp`) already uses. + case .skinTemp: return "thermometer.medium" } } @@ -59,7 +61,7 @@ extension KeyMetric { case .restingHr: return StrandPalette.metricRose case .bloodOxygen, .steps: return StrandPalette.metricCyan case .respiratory, .weight: return StrandPalette.accent - case .calories: return StrandPalette.metricAmber + case .calories, .skinTemp: return StrandPalette.metricAmber } } } @@ -76,6 +78,7 @@ extension DashboardCard { case .skinTemp, .calories: return StrandPalette.metricAmber case .sleep: return StrandPalette.restColor case .coupled: return StrandPalette.chargeColor + case .coach: return StrandPalette.accent } } } diff --git a/Strand/Screens/TodayView.swift b/Strand/Screens/TodayView.swift index aeb367be35..63cf80530a 100644 --- a/Strand/Screens/TodayView.swift +++ b/Strand/Screens/TodayView.swift @@ -65,24 +65,32 @@ private struct HRChartFrameKey: PreferenceKey { struct ActiveWorkoutIndicatorModel: Equatable { let sport: String let startedAt: Date + /// The pause state has to be CARRIED, not just consulted: this value type is what the card renders + /// from, so without these two fields the indicator cannot subtract the paused time or say it is + /// paused, however correct `AppModel` is. That is precisely how it kept counting through #1533. + var pausedAt: Date? = nil + var pausedDuration: TimeInterval = 0 + + var isPaused: Bool { pausedAt != nil } static func make(from workout: AppModel.ActiveWorkout?) -> ActiveWorkoutIndicatorModel? { guard let workout else { return nil } - return ActiveWorkoutIndicatorModel(sport: workout.sport, startedAt: workout.start) + return ActiveWorkoutIndicatorModel(sport: workout.sport, startedAt: workout.start, + pausedAt: workout.pausedAt, + pausedDuration: workout.pausedDuration) } - /// Elapsed time since `start`, formatted M:SS up to an hour and H:MM:SS once an hour has passed (so a + /// Elapsed ACTIVE time, formatted M:SS up to an hour and H:MM:SS once an hour has passed (so a /// 90-minute session reads "1:30:00", not "90:00"). Clamped at zero so a clock-skew negative reads 0:00. /// Pure + injectable `now` for deterministic tests. (StrandFont.bodyNumber already applies tabular figures, /// so the call site does NOT add `.monospacedDigit()`.) - static func elapsed(since start: Date, now: Date = Date()) -> String { - let total = max(0, Int(now.timeIntervalSince(start))) - let h = total / 3600 - let m = (total % 3600) / 60 - let s = total % 60 - return h > 0 - ? String(format: "%d:%02d:%02d", h, m, s) - : String(format: "%d:%02d", m, s) + /// + /// `pausedAt`/`pausedDuration` default to "never paused" so the existing call sites and tests that + /// predate pause keep their exact meaning; the math itself lives in `ActiveWorkoutClock`. + static func elapsed(since start: Date, pausedAt: Date? = nil, pausedDuration: TimeInterval = 0, + now: Date = Date()) -> String { + ActiveWorkoutClock.clock(Int(ActiveWorkoutClock.activeElapsed( + start: start, pausedAt: pausedAt, pausedDuration: pausedDuration, now: now))) } } @@ -103,12 +111,22 @@ private struct ActiveWorkoutIndicatorCard: View { .font(StrandFont.overline) .tracking(StrandFont.overlineTracking) .foregroundStyle(StrandPalette.metricRose) + // A frozen clock alone is ambiguous with a STALLED one, so say which it is. Reuses the + // "Paused" string #1533 already localized rather than minting new copy for a tag. + if model.isPaused { + Text("Paused") + .font(StrandFont.overline) + .tracking(StrandFont.overlineTracking) + .foregroundStyle(StrandPalette.textSecondary) + } Spacer(minLength: NoopMetrics.space2) // A per-second live clock. The TimelineView re-evaluates ONLY this Text every second, so // the tick never re-renders the rest of the card (let alone TodayView.body). bodyNumber // already carries `.monospacedDigit()`, so no extra modifier here. TimelineView(.periodic(from: .now, by: 1)) { context in - Text(ActiveWorkoutIndicatorModel.elapsed(since: model.startedAt, now: context.date)) + Text(ActiveWorkoutIndicatorModel.elapsed( + since: model.startedAt, pausedAt: model.pausedAt, + pausedDuration: model.pausedDuration, now: context.date)) .font(StrandFont.bodyNumber) .foregroundStyle(StrandPalette.textPrimary) } @@ -169,6 +187,8 @@ struct ActiveWorkoutIndicatorSection: View { } struct TodayView: View { + @AppStorage(DayCycleMode.storageKey) private var dayCycleModeRaw = DayCycleMode.sleepOnset.rawValue + private var dayCycleMode: DayCycleMode { DayCycleMode.persisted(dayCycleModeRaw) } /// Product mark, never natural-language copy. Keeping it out of localization also makes source /// classification and tint selection stable when the app language changes. private static let whoopBrandName = "WHOOP" @@ -209,6 +229,7 @@ struct TodayView: View { /// `MetricExplorerView`, Liquid Today): the explicit override when set, else derived from the unit /// system. This screen used to print a bare `%+.1f°` and was the last one ignoring the preference. @AppStorage(UnitPrefs.temperatureKey) private var temperatureRaw = "" + @AppStorage(UnitPrefs.skinTempDisplayKey) private var skinTempDisplayRaw = "" // #1846 private var temperatureUnit: TemperatureUnit { UnitPrefs.resolveTemperature(system: unitSystem, override: temperatureRaw) } @@ -274,6 +295,10 @@ struct TodayView: View { // the deferred set immediately (belt-and-braces alongside the coalesced refreshSeq bump). A bare boolean // that flips ~twice per offload, so it costs nothing like the per-tick chunk count would. @State private var liveBackfillingFlag = false + // #1164: mirror of `LiveState.historyPendingSync` (strap has banked records newer than our frontier). + // Bridged through the same `BackfillFlagBridge` as `liveBackfillingFlag` (no second LiveState observer). + // Drives the Today Rest "Pending sync" state so a provisional score isn't shown as final. + @State private var liveHistoryPendingSyncFlag = false // #755: have the history-wide reads ever populated this session? Used so the FIRST load always runs them // (even mid-offload, so a cold launch during a sync is never a blank dashboard), while later re-loads can // safely defer them during an active backfill. @@ -282,6 +307,14 @@ struct TodayView: View { // 14-day sparkline series, keyed by metric key. Loaded once in .task. @State private var sparks: [String: [Double]] = [:] @State private var workouts: [WorkoutRow] = [] + /// #1694: a tapped Latest-Workouts tile. Wrapped so `.sheet(item:)` drives presentation, mirroring + /// WorkoutsView's own detail target — the feed was read-only, so the only route to a session's + /// detail was More > Workouts. + private struct WorkoutDetailTarget: Identifiable { + let row: WorkoutRow + let id = UUID() + } + @State private var workoutDetail: WorkoutDetailTarget? @State private var appleDays: [AppleDaily] = [] // Design Reset / #582, the pinned "Your cards" values (Stress / Fitness age / Vitality), surfaced // on Today so the buried Explore features sit on the home screen. Loaded in loadAll; nil hides the row. @@ -374,6 +407,9 @@ struct TodayView: View { @State private var showSettings = false @State private var showLiveSession = false /// The Updates inbox sheet (opened by the header bell). Shared across both platforms. + /// #1862: the optional Coach launcher sheet, opened from the default-OFF Coach dashboard card. + /// Presentation state only — nothing is requested from a provider by opening it. + @State private var showCoachLauncher = false @State private var showUpdatesInbox = false /// The NEWEST day-key (max yyyy-MM-dd in `repo.days`) announced to the inbox. Persisted (not @State) @@ -564,10 +600,43 @@ struct TodayView: View { return Repository.lastSpo2Day(days: repo.days, todayKey: displayDay?.day ?? selectedDayKey) } - /// PER-FIELD skin-temperature-deviation carry — twin of `lastSpo2Day`; mirrors the Android `lastSkinTempRow`. - private var lastSkinTempDay: DailyMetric? { + /// PER-FIELD HRV carry — twin of `lastSpo2Day` for a field `lastVitalsDay`'s OR predicate checks but can + /// still resolve nil on: it picks the freshest row with ANY vital, so a respiratory-only row blanks HRV + /// (#1842). Mirrors the Android `lastHrvRow`. + private var lastHrvDay: DailyMetric? { + guard selectedDayOffset == 0 else { return nil } + return Repository.lastHrvDay(days: repo.days, todayKey: displayDay?.day ?? selectedDayKey) + } + + /// PER-FIELD resting-HR carry — twin of `lastHrvDay`. Mirrors the Android `lastRestingHrRow`. + private var lastRestingHrDay: DailyMetric? { guard selectedDayOffset == 0 else { return nil } - return Repository.lastSkinTempDay(days: repo.days, todayKey: displayDay?.day ?? selectedDayKey) + return Repository.lastRestingHrDay(days: repo.days, todayKey: displayDay?.day ?? selectedDayKey) + } + + /// The carry for the surfaces that LEAD WITH THE ABSOLUTE (#1844): the freshest strictly-prior row + /// holding EITHER skin-temp number, so a calibrating night — real temperature, no deviation yet — is + /// found instead of being skipped for an older deviation. Mirrors the Android `lastSkinTempReadingRow`. + private var lastSkinTempReadingDay: DailyMetric? { + guard selectedDayOffset == 0 else { return nil } + return Repository.lastSkinTempReadingDay(days: repo.days, todayKey: displayDay?.day ?? selectedDayKey) + } + + /// The skin-temp reading a Today surface leads with: today's row if it has either number, else the + /// carry. Both numbers always come off the SAME row, so an absolute is never paired with another + /// night's deviation. + private var skinTempLeadReading: SkinTempDisplay.Reading? { + let row = [displayDay, lastVitalsDay, lastSkinTempReadingDay] + .compactMap { $0 } + .first { $0.skinTempC != nil || $0.skinTempDevC != nil } + return SkinTempDisplay.leadReading(absC: row?.skinTempC, devC: row?.skinTempDevC, + prefer: skinTempPreferred) + } + + /// The user's Settings choice (#1846), resolved from the stored raw. Absent/unrecognised reads as + /// `.absolute`, so an install that never opens Settings behaves exactly as before the setting existed. + private var skinTempPreferred: SkinTempDisplay.Kind { + SkinTempDisplay.Kind(rawValue: skinTempDisplayRaw) ?? .absolute } /// PER-FIELD respiratory carry, and the only one of these that is STALENESS-BOUNDED @@ -664,6 +733,26 @@ struct TodayView: View { return lastValue } + /// #1164 — should today's Rest show "Pending sync" instead of a provisional number? When the strap has + /// banked records not yet offloaded, the Rest score is computed from partial data and will change once + /// the full night lands and `analyzeRecent` re-scores it. Surfacing it as "Pending sync" rather than a + /// confident number that then moves reads honestly instead of as a bug. + /// + /// Two honest signals, either of which means more data is expected: + /// - `backfilling`: an offload is actively running right now (data is draining). + /// - `historyPendingSync`: the strap reports banked records newer than our local frontier (the strap + /// has data we haven't ingested yet, even when no offload is running — e.g. right after connect, + /// before the first offload starts). + /// + /// Only applies to TODAY (a past day's score is final — no more data is coming for it) and only when a + /// Rest score EXISTS (pending suppresses a provisional number; it does not fabricate one when there is + /// none). Pure + unit-testable. Mirror EXACTLY in Kotlin. + static func restPendingSync(restScore: Double?, backfilling: Bool, + historyPendingSync: Bool, isTodaySelected: Bool) -> Bool { + guard isTodaySelected, restScore != nil else { return false } + return backfilling || historyPendingSync + } + /// The carried recovery caption stamp, keyed on that scored day's own date and its recency. Within the /// freshness cap it reads "Last night · "; once the carried day is older than the cap (#779) it /// reads "Latest sleep · " so a weeks-old import is never surfaced as "Last night". Shared by every @@ -1043,6 +1132,16 @@ struct TodayView: View { stale > Baselines.staleDays { return "No new nights from your strap for \(stale) days. Check it's connected and saving data." } + // #612 covers a TOTAL drought (nothing valid for staleDays). The common shape is the other one: + // nights arriving, most of them empty — five days in with three HRV-less nights sits at "2 of 4" + // with no reason given, which reads as a stuck counter. Name the missing nights so the wearer has + // something to act on instead of something to wait for. + let cov = Baselines.recentHrvCoverage(dayKeys: repo.days.map(\.day), + nightlyHrv: repo.days.map(\.avgHrv), + today: Repository.logicalDayKey(Date())) + if cov.missing > 0, cov.observed > 0 { + return "Learning your baseline, \(n) of \(Baselines.minNightsSeed) nights. \(cov.missing) of the last \(cov.observed) nights recorded no HRV. Check the strap is worn overnight and syncing." + } return "Learning your baseline, \(n) of \(Baselines.minNightsSeed) nights." } @@ -1415,11 +1514,13 @@ struct TodayView: View { // zero-size leaf in `.background` (no layout impact) that owns the observation and pushes only // the boolean EDGE up. loadAll reads the flag to defer the heavy history-wide reads during an // active offload; the off→false edge below re-runs them as a safety net to the coalesced refresh. - .background(BackfillFlagBridge(flag: $liveBackfillingFlag)) + .background(BackfillFlagBridge(flag: $liveBackfillingFlag, + pendingSyncFlag: $liveHistoryPendingSyncFlag)) } // Reload when the data refreshes OR the selected day changes, the HR trend and Rest score are // day-scoped, so navigating must re-fetch them for the newly selected window. - .task(id: TodayLoadKey(seq: repo.refreshSeq, offset: selectedDayOffset)) { await loadAll() } + .task(id: TodayLoadKey(seq: repo.refreshSeq, offset: selectedDayOffset, + dayCycleMode: dayCycleModeRaw)) { await loadAll() } // #989: hydration writes don't bump refreshSeq, so the card needs its own triggers, a logged / // edited / deleted drink (hydrationSeq) and the Settings feature toggle both re-read just the two // hydration fields. Cheap (one metricSeries row), never re-runs the heavy loads. @@ -1468,6 +1569,9 @@ struct TodayView: View { ScoringGuideView(onClose: { showGuideTop = false }) } // The Updates inbox (the header bell). Both platforms. + .sheet(isPresented: $showCoachLauncher) { + CoachLauncherSheet() + } .sheet(isPresented: $showUpdatesInbox) { UpdatesInboxView(onClose: { showUpdatesInbox = false }) } @@ -1493,6 +1597,20 @@ struct TodayView: View { hostedCardsRaw: $hostedCardsRaw ) } + // #1694: the same read-only WorkoutDetailView the Workouts list opens. Nothing here can edit or + // delete, so a tap from Today carries no risk that list does not already carry. Rides its own + // NavigationStack because these shared screens are not in a per-screen one — mirrors WorkoutsView. + .sheet(item: $workoutDetail) { target in + NavigationStack { + WorkoutDetailView(row: target.row) + .environmentObject(repo) + } + #if os(iOS) + .noopSheetPresentation(largeFirst: true) + #else + .frame(width: 620, height: 720) + #endif + } #if os(iOS) .fullScreenCover(isPresented: $showLiveSession) { LiveSessionView(onClose: { showLiveSession = false }) @@ -2495,6 +2613,11 @@ struct TodayView: View { // coupled day screen. An empty value renders just the icon + title + subtitle + chevron. pinnedCardRow(icon: card.icon, tint: tint, title: card.title, subtitle: card.subtitle, value: dashboardValue(card), route: .coupled) + case .coach: + // #1862: a SHEET, not a push — Coach is a thing you dip into and dismiss, and pushing it + // would take you off Today, which is the discoverability problem this card exists to solve. + pinnedCardActionRow(icon: card.icon, tint: tint, title: card.title, subtitle: card.subtitle, + value: dashboardValue(card)) { showCoachLauncher = true } } } @@ -2516,6 +2639,7 @@ struct TodayView: View { case .calories: return StrandPalette.metricAmber case .hydration: return StrandPalette.metricCyan case .coupled: return StrandPalette.chargeColor + case .coach: return StrandPalette.accent } } @@ -2534,12 +2658,18 @@ struct TodayView: View { #if DEBUG if let f = DemoDayHarness.active { return withUnit("\(f.hrvMs)") } #endif - return withUnit(d?.avgHrv.map { "\(Int($0.rounded()))" } ?? "—") + // PER-FIELD carry: today → the freshest prior row that actually HAS an HRV (#1842). Was + // today-only, so this card blanked to "—" every rollover while the Key Metrics tile — which + // has carried via `carriedVital(perField:)` all along — showed a number on the same screen. + // Not the whole-row `lastVitalsDay`: its OR predicate resolves nil HRV on a respiratory-only + // row. Mirrors the Android dashboardCardValue. + return withUnit((d?.avgHrv ?? lastHrvDay?.avgHrv).map { "\(Int($0.rounded()))" } ?? "—") case .restingHr: #if DEBUG if let f = DemoDayHarness.active { return withUnit("\(f.rhrBpm)") } #endif - return withUnit(d?.restingHr.map { "\($0)" } ?? "—") + // PER-FIELD carry — twin of `.hrv` above (#1842). + return withUnit((d?.restingHr ?? lastRestingHrDay?.restingHr).map { "\($0)" } ?? "—") case .respiratory: // PER-FIELD carry: today → the STALENESS-BOUNDED prior night (`lastRespDay`). Recovery- // independent, so a night with real R-R but a null recovery still carries. @@ -2578,9 +2708,9 @@ struct TodayView: View { // deviation, and converts a DELTA by the scale factor alone rather than the absolute formula. // The card's own unit is deliberately empty — the value carries "°C" / "Δ°F" itself. // Same per-field carry as Blood Oxygen; both are sparse enough that an old reading is honest. - return Self.skinTempCardValue( - d?.skinTempDevC ?? lastVitalsDay?.skinTempDevC ?? lastSkinTempDay?.skinTempDevC, - fahrenheit: temperatureUnit == .fahrenheit) + // #1844: lead with the night's measured ABSOLUTE when it has one (the #1665 rule, applied + // here too) — a deviation with no anchor cannot be read. Deviation-only nights are unchanged. + return Self.skinTempCardValue(reading: skinTempLeadReading, fahrenheit: temperatureUnit == .fahrenheit) case .sleep: return sleepValue(d) case .steps: @@ -2618,6 +2748,10 @@ struct TodayView: View { // A tap-through row with no metric value of its own, the row shows just the chevron. Returning // an empty string (not "—") renders no number and leaves it un-dimmed (it isn't a missing value). return "" + case .coach: + // #1862: likewise a launcher row. Empty rather than "—" for the same reason — there is no + // missing measurement here, there is no measurement at all. + return "" } } @@ -2629,37 +2763,56 @@ struct TodayView: View { private func pinnedCardRow(icon: String, tint: Color, title: String, subtitle: String, value: String, route: TabRoute) -> some View { NavigationLink(value: route) { - HStack(spacing: 12) { - RoundedRectangle(cornerRadius: 9, style: .continuous) - .fill(tint.opacity(0.14)) - .frame(width: 34, height: 34) - .overlay(Image(systemName: icon).font(.system(size: 15, weight: .semibold)).foregroundStyle(tint)) - VStack(alignment: .leading, spacing: 2) { - Text(title.uppercased()) - .font(StrandFont.overline) - .tracking(StrandFont.overlineTracking) - .foregroundStyle(StrandPalette.textPrimary) - .lineLimit(1) - Text(subtitle) - .font(StrandFont.footnote) - .foregroundStyle(StrandPalette.textTertiary) - .lineLimit(1) - } - Spacer(minLength: 8) - // A real number reads white; a placeholder (, / Calibrating) reads dimmed so it doesn't - // masquerade as a value. - let isPlaceholder = (value == "—" || value == Self.calibratingPlaceholder) - Text(value).font(StrandFont.rounded(18, weight: .semibold)) - .foregroundStyle(isPlaceholder ? StrandPalette.textTertiary : StrandPalette.textPrimary) - Image(systemName: "chevron.right").font(.system(size: 13, weight: .semibold)) + pinnedCardRowBody(icon: icon, tint: tint, title: title, subtitle: subtitle, value: value) + } + .buttonStyle(.plain) + } + + /// The same row, but it runs `action` instead of pushing a route (#1862). + /// + /// Coach is the one dashboard card that opens a SHEET rather than a screen, so it cannot ride + /// `NavigationLink`. Both wrappers render `pinnedCardRowBody`, so the two kinds of row cannot drift + /// apart visually — which duplicating the HStack for one caller would have guaranteed eventually. + private func pinnedCardActionRow(icon: String, tint: Color, title: String, subtitle: String, + value: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + pinnedCardRowBody(icon: icon, tint: tint, title: title, subtitle: subtitle, value: value) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func pinnedCardRowBody(icon: String, tint: Color, title: String, subtitle: String, + value: String) -> some View { + HStack(spacing: 12) { + RoundedRectangle(cornerRadius: 9, style: .continuous) + .fill(tint.opacity(0.14)) + .frame(width: 34, height: 34) + .overlay(Image(systemName: icon).font(.system(size: 15, weight: .semibold)).foregroundStyle(tint)) + VStack(alignment: .leading, spacing: 2) { + Text(title.uppercased()) + .font(StrandFont.overline) + .tracking(StrandFont.overlineTracking) + .foregroundStyle(StrandPalette.textPrimary) + .lineLimit(1) + Text(subtitle) + .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) + .lineLimit(1) } - .padding(.horizontal, 13).padding(.vertical, 11) - .frame(maxWidth: .infinity, alignment: .leading) - .background(FrostedCardSurface(cornerRadius: NoopMetrics.cardRadius)) - .contentShape(Rectangle()) + Spacer(minLength: 8) + // A real number reads white; a placeholder (, / Calibrating) reads dimmed so it doesn't + // masquerade as a value. + let isPlaceholder = (value == "—" || value == Self.calibratingPlaceholder) + Text(value).font(StrandFont.rounded(18, weight: .semibold)) + .foregroundStyle(isPlaceholder ? StrandPalette.textTertiary : StrandPalette.textPrimary) + Image(systemName: "chevron.right").font(.system(size: 13, weight: .semibold)) + .foregroundStyle(StrandPalette.textTertiary) } - .buttonStyle(.plain) + .padding(.horizontal, 13).padding(.vertical, 11) + .frame(maxWidth: .infinity, alignment: .leading) + .background(FrostedCardSurface(cornerRadius: NoopMetrics.cardRadius)) + .contentShape(Rectangle()) } // MARK: Component 2, explained score note (calibrating / carried / needs-strap) @@ -2753,16 +2906,26 @@ struct TodayView: View { // falls back to the last night that recorded THAT vital (`lastVitalsDay`, recovery-INDEPENDENT — a // night with real HRV/RHR but a null recovery is a valid source, which the old `lastScoredRecoveryDay` // row-swap skipped). Today's own value always wins the instant it lands. + // ...and PER FIELD means per field: `lastVitalsDay`'s predicate is an OR across the three, so it + // resolves the freshest row with ANY of them and blanks a vital that row happens to lack (#1842). + let hd = lastHrvDay + let rd = lastRestingHrDay let vd = lastVitalsDay - let hrv = d?.avgHrv ?? vd?.avgHrv - let rhr = d?.restingHr ?? vd?.restingHr + let hrv = d?.avgHrv ?? hd?.avgHrv + let rhr = d?.restingHr ?? rd?.restingHr let resp = d?.respRateBpm ?? vd?.respRateBpm // The provenance row a shown vital fell back to (nil when every shown vital is today's own): stamps // that row's own date, so the footnote can't claim "Last night" for a value that IS today's. - let carriedFromHrv = d?.avgHrv == nil && vd?.avgHrv != nil - let carriedFromRhr = d?.restingHr == nil && vd?.restingHr != nil - let carriedFromResp = d?.respRateBpm == nil && vd?.respRateBpm != nil - let provenance: DailyMetric? = (carriedFromHrv || carriedFromRhr || carriedFromResp) ? vd : nil + // Each vital can now carry from a DIFFERENT row, so the one card-level footnote stamps the OLDEST + // row any SHOWN carried vital came from. Erring old is the only safe direction for a caption whose + // job is to stop a stale read passing as today's, and it keeps `carriedCaption`'s "Latest sleep" + // relabel (#779) firing on the value that actually is weeks old. A row is only a source if it + // SUPPLIED the value — `vd` can hold a nil respiratory, which carries nothing and stamps nothing. + let carriedFromHrv: DailyMetric? = (d?.avgHrv == nil && hd?.avgHrv != nil) ? hd : nil + let carriedFromRhr: DailyMetric? = (d?.restingHr == nil && rd?.restingHr != nil) ? rd : nil + let carriedFromResp: DailyMetric? = (d?.respRateBpm == nil && vd?.respRateBpm != nil) ? vd : nil + let sources: [DailyMetric] = [carriedFromHrv, carriedFromRhr, carriedFromResp].compactMap { $0 } + let provenance: DailyMetric? = sources.min(by: { $0.day < $1.day }) NoopCard(tint: StrandPalette.chargeColor) { VStack(spacing: 0) { // DEBUG promo harness: pin HRV / Resting HR to the active frame's values. No-op otherwise. @@ -2775,11 +2938,11 @@ struct TodayView: View { #endif metricRow(icon: "waveform.path.ecg", label: "HRV", value: demoHrv ?? (hrv.map { "\(Int($0.rounded()))" } ?? "—"), unit: "ms", - tint: StrandPalette.metricCyan) + tint: StrandPalette.metricCyan, route: .metric("hrv")) Divider().overlay(StrandPalette.hairline) metricRow(icon: "heart.fill", label: "Resting HR", value: demoRhr ?? (rhr.map { "\($0)" } ?? "—"), unit: "bpm", - tint: StrandPalette.metricRose) + tint: StrandPalette.metricRose, route: .metric("rhr")) Divider().overlay(StrandPalette.hairline) metricRow(icon: "lungs.fill", label: "Respiratory", // Today's own respiratory, else the carried night's; a non-carrying today keeps the @@ -2787,7 +2950,7 @@ struct TodayView: View { value: resp.map { String(format: "%.1f", locale: AppLanguage.activeLocale, $0) } ?? (vd == nil ? latestString("resp_rate", decimals: 1) : "—"), unit: "rpm", - tint: StrandPalette.accent) + tint: StrandPalette.accent, route: .metric("resp_rate")) // ONE provenance footnote when a shown vital is a carried prior-day read (not today's), // stamped with THAT row's date via the shared caption (which relabels a weeks-old carry to // "Latest sleep", #779), so a prior read is never silently passed off as today. @@ -2813,7 +2976,26 @@ struct TodayView: View { /// One README "metric row": a metric-hue line icon, a secondary label, and a right-aligned bold /// value with a small unit. Rows are divided by a hairline. Shared by the Today vitals card. @ViewBuilder - private func metricRow(icon: String, label: LocalizedStringKey, value: String, unit: String, tint: Color) -> some View { + /// A vitals row, optionally pushing its own metric trend (#706/#684). + /// + /// `route: nil` renders exactly what shipped before - no link, no chevron - so the three other callers + /// are untouched and a row that goes nowhere never claims otherwise. `LiquidPressStyle` is not + /// decoration: a bare `NavigationLink` applies the default link chrome and would tint the whole row, + /// which is why `cardLink` carries it too. + private func metricRow(icon: String, label: LocalizedStringKey, value: String, unit: String, + tint: Color, route: TabRoute? = nil) -> some View { + Group { + if let route { + NavigationLink(value: route) { metricRowBody(icon, label, value, unit, tint, linked: true) } + .buttonStyle(LiquidPressStyle()) + } else { + metricRowBody(icon, label, value, unit, tint, linked: false) + } + } + } + + private func metricRowBody(_ icon: String, _ label: LocalizedStringKey, _ value: String, + _ unit: String, _ tint: Color, linked: Bool) -> some View { HStack(spacing: 12) { Image(systemName: icon) .font(.system(size: 15, weight: .semibold)) @@ -2837,6 +3019,12 @@ struct TodayView: View { .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) } + // Only when the row goes somewhere: a row that cannot navigate must not imply it can. + if linked { + Image(systemName: "chevron.right").font(.system(size: 12, weight: .semibold)) + .foregroundStyle(StrandPalette.textTertiary) + .accessibilityHidden(true) + } } .padding(.vertical, 13) .accessibilityElement(children: .combine) @@ -3149,7 +3337,14 @@ struct TodayView: View { /// Rest (sleep composite 0–100) hero ring. @ViewBuilder private func restRing(diameter: CGFloat) -> some View { - if let s = restScore { + // #1164: when the strap has banked records not yet offloaded, today's Rest is provisional — it + // will change once the full night lands and `analyzeRecent` re-scores it. Show "Pending sync" + // instead of a confident number that then moves. Past days are final (no more data coming). + if Self.restPendingSync(restScore: restScore, backfilling: liveBackfillingFlag, + historyPendingSync: liveHistoryPendingSyncFlag, + isTodaySelected: selectedDayOffset == 0) { + emptyHeroRing(diameter: diameter) { ringPendingSync() } + } else if let s = restScore { GlowRing(fraction: s / 100, value: s, format: { "\(Int($0.rounded()))" }, color: StrandPalette.restColor, diameter: diameter, lineWidth: diameter * 0.10) } else if displayDay?.recovery != nil { @@ -3177,6 +3372,20 @@ struct TodayView: View { } } + /// #1164: the Rest ring's overlay when today's score is provisional because the strap still has + /// banked records not yet offloaded. Shows "Pending sync" instead of a number that will change once + /// the full night lands and `analyzeRecent` re-scores it. Mirrors Android's RingPendingSync. + @ViewBuilder + private func ringPendingSync() -> some View { + VStack(spacing: 3) { + Text("Pending sync").font(StrandFont.headline).foregroundStyle(StrandPalette.textTertiary) + .lineLimit(1).minimumScaleFactor(0.7).fixedSize() + Text("strap history still offloading").font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .lineLimit(1).minimumScaleFactor(0.6).fixedSize() + } + } + /// The faint full-circle track with a centred overlay, shown when a score is still calibrating/absent. @ViewBuilder private func emptyHeroRing(diameter: CGFloat, @ViewBuilder overlay: () -> Overlay) -> some View { @@ -3244,8 +3453,14 @@ struct TodayView: View { // the same lineLimit/scaleFactor guard so it never wraps, then its "N of 4" subtitle below. Text("Calibrating").font(StrandFont.headline).foregroundStyle(StrandPalette.textPrimary) .lineLimit(1).minimumScaleFactor(0.7).fixedSize() - Text("\(n) of \(Baselines.minNightsSeed)").font(StrandFont.footnote).foregroundStyle(StrandPalette.textSecondary) - .lineLimit(1) + // #1816's lesson on a second tile: a bare "2 of 4" under "Calibrating" is read as DAYS, + // and a wearer five days in reports it stuck. It counts NIGHTS THAT BANKED A USABLE HRV + // (`Baselines.update` only advances `nValid` for a non-nil in-range value), so a week of + // wear with three R-R-less nights genuinely sits at 2. Naming the unit is the whole fix: + // the number is right, the reader's unit was not. + Text("\(n) of \(Baselines.minNightsSeed) nights").font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .lineLimit(1).minimumScaleFactor(0.7) } else { ringNoData(diameter: diameter) } @@ -3641,23 +3856,40 @@ struct TodayView: View { accessory: { scoreInfoButton(.effort) } ) case .rest: - // Unscored TODAY → "building, wear it tonight" instead of a lone ", " caption (#527); - // a scored day keeps its sleep-duration / efficiency caption. - StatTile( - label: "Rest", - value: restScore.map { "\(Int($0.rounded()))%" } ?? "—", - // Component 2: a scored day shows its duration/efficiency caption; an unscored TODAY shows - // the "building" hint; a past day with no Rest falls to the honest "Needs the strap" rather - // than a bare blank, so the tile always carries a state. - caption: restScore != nil ? restCaption(d) - : (buildingHint(.rest) ?? restCaption(d) ?? Self.needsStrapCaption), - accent: restScore.map { StrandPalette.recoveryColor($0) } ?? StrandPalette.textPrimary, - // The Rest composite (0–100) trend, not raw sleep minutes, tracks the score above (#614). - sparkline: sparks["sleep_performance"], - sparkColor: StrandPalette.metricPurple, - // Inline ⓘ in the tile header (not a corner overlay) so it never sits over the value (#495). - accessory: { scoreInfoButton(.rest) } - ) + // #1164: when today's Rest is provisional (strap has banked records not yet offloaded), show + // "Pending sync" instead of a number that will change once the full night lands. Past days + // are final (no more data coming), so the pending state is today-only. + if Self.restPendingSync(restScore: restScore, backfilling: liveBackfillingFlag, + historyPendingSync: liveHistoryPendingSyncFlag, + isTodaySelected: selectedDayOffset == 0) { + StatTile( + label: "Rest", + value: "—", + caption: String(localized: "Pending sync · strap history still offloading"), + accent: StrandPalette.textPrimary, + sparkline: sparks["sleep_performance"], + sparkColor: StrandPalette.metricPurple, + accessory: { scoreInfoButton(.rest) } + ) + } else { + // Unscored TODAY → "building, wear it tonight" instead of a lone ", " caption (#527); + // a scored day keeps its sleep-duration / efficiency caption. + StatTile( + label: "Rest", + value: restScore.map { "\(Int($0.rounded()))%" } ?? "—", + // Component 2: a scored day shows its duration/efficiency caption; an unscored TODAY shows + // the "building" hint; a past day with no Rest falls to the honest "Needs the strap" rather + // than a bare blank, so the tile always carries a state. + caption: restScore != nil ? restCaption(d) + : (buildingHint(.rest) ?? restCaption(d) ?? Self.needsStrapCaption), + accent: restScore.map { StrandPalette.recoveryColor($0) } ?? StrandPalette.textPrimary, + // The Rest composite (0–100) trend, not raw sleep minutes, tracks the score above (#614). + sparkline: sparks["sleep_performance"], + sparkColor: StrandPalette.metricPurple, + // Inline ⓘ in the tile header (not a corner overlay) so it never sits over the value (#495). + accessory: { scoreInfoButton(.rest) } + ) + } case .hrv: // Carry the last scored night's HRV at the rollover (#543), today's wins, the carried value // is stamped "Last night · ", and a never-scored metric still shows ", ". @@ -3808,27 +4040,64 @@ struct TodayView: View { sparkline: sparks["active_kcal"], sparkColor: StrandPalette.metricAmber ) + case .skinTemp: + // Added 2026-08-24 (queue 11c follow-up): first Key Metrics appearance for Skin Temp — was + // already a "Your Cards" tile (`DashboardCard.skinTemp`), never a Key Metrics one. Reuses the + // SAME value chain and `skinTempCardValue` formatter the "Your Cards" case above already + // uses, so the two tiles can never disagree. + let skinTempValue = skinTempLeadReading + StatTile( + label: "Skin Temp", + value: Self.skinTempCardValue(reading: skinTempValue, fahrenheit: temperatureUnit == .fahrenheit), + caption: skinTempValue == nil ? Self.needsStrapCaption : "", + accent: skinTempValue == nil ? StrandPalette.textPrimary : StrandPalette.metricAmber, + sparkline: sparks["skin_temp"], + sparkColor: StrandPalette.metricAmber + ) } } // MARK: (c) LAST WORKOUTS, SAME grid, uniform 104pt workout tiles. + /// Android's Today feed contract (`TodayScreen.recentCutoff`): sessions starting on or after the + /// start of the day 13 days back — 14 days counting today. Named rather than inlined so the window + /// is one thing on this platform too, and so the parity guard has something to point at. + static func recentWorkoutsFeed(_ rows: [WorkoutRow], now: Date = Date()) -> [WorkoutRow] { + let cal = Calendar.current + guard let cutoff = cal.date(byAdding: .day, value: -13, to: cal.startOfDay(for: now)) else { return rows } + let cutoffTs = Int(cutoff.timeIntervalSince1970) + return rows.filter { $0.startTs >= cutoffTs } + } + @ViewBuilder private var workoutsSection: some View { - if !workouts.isEmpty { + // #1702: window HERE, not on `workouts`. That array is shared — it also feeds the Data Sources + // Apple-workout count and the HR chart's sport glyphs, both all-time by design — so windowing it + // at the source would silently shrink two unrelated numbers on this same screen. + let recent = Self.recentWorkoutsFeed(workouts) + if !recent.isEmpty { VStack(alignment: .leading, spacing: NoopMetrics.gap) { + // "14 days" describes the window, like Android's today_workouts_14_days. The old + // "\(count) total" counted every workout ever recorded while showing at most six. SectionHeader("Latest Workouts", overline: "Activity", - trailing: String(localized: "\(workouts.count) total")) + trailing: String(localized: "14 days")) LazyVGrid(columns: grid, alignment: .leading, spacing: NoopMetrics.gap) { - ForEach(Array(workouts.prefix(6).enumerated()), id: \.offset) { _, w in - StatTile( - label: "\(WorkoutSource.displaySport(w.sport))", - value: workoutDuration(w), - caption: workoutCaption(w), - accent: StrandPalette.effortTint(fraction: (w.strain ?? 0) / StrainScorer.maxStrain), - delta: w.energyKcal.map { "\(Int($0.rounded())) kcal" }, - deltaColor: StrandPalette.metricAmber - ) + ForEach(Array(recent.prefix(6).enumerated()), id: \.offset) { _, w in + Button { + workoutDetail = WorkoutDetailTarget(row: w) + } label: { + StatTile( + label: "\(WorkoutSource.displaySport(w.sport))", + value: workoutDuration(w), + caption: workoutCaption(w), + accent: StrandPalette.effortTint(fraction: (w.strain ?? 0) / StrainScorer.maxStrain), + delta: w.energyKcal.map { "\(Int($0.rounded())) kcal" }, + deltaColor: StrandPalette.metricAmber + ) + } + // The Workouts list's own rows use this, not .plain: it is the iOS twin of + // Android's liquidPress, so the tile settles inward on press on both platforms. + .buttonStyle(LiquidPressStyle()) } } } @@ -4116,13 +4385,35 @@ struct TodayView: View { /// number, and there is nothing for the user to go and do. `StatTile.caption` is optional, so nil /// renders NO caption rather than falling back to "today" — which would be its own small lie on a past /// day being browsed. - /// Twin of the Kotlin `stepsCalibrationPrompt` guard (#1514). + /// + /// #1816: when the strap has banked NO motion, the phone-step-days countdown is the wrong message. + /// A step estimate is `motion * coefficient`, so with the motion half missing neither the estimate + /// nor the fit moves however many days the phone counts — and the countdown that names only the + /// phone half sent a field reporter to enter Apple Health steps by hand expecting calibration to + /// start, which it cannot. The `stepsHasBankedMotion` flag is persisted by `IntelligenceEngine` on + /// every analytics pass, so it tracks a fresh strap's first sync without a per-render query. When + /// it is false, the caption says "No motion synced yet" instead — the same wording the calibration + /// sheet's no-motion banner uses, so the two surfaces agree. Twin of the Kotlin + /// `stepsCalibrationPrompt` guard (#1514). private var stepsCalibrationCaption: String? { - guard profile.stepsCalibrationCoefficient <= 0, profile.stepsManualCoefficient <= 0 else { - return nil - } + Self.stepsCalibrationCaption(coefficient: profile.stepsCalibrationCoefficient, + manualCoefficient: profile.stepsManualCoefficient, + hasBankedMotion: profile.stepsHasBankedMotion, + sampleDays: profile.stepsCalibrationSampleDays) + } + + /// #1816: the pure decision behind `stepsCalibrationCaption`, extracted so it can be unit-tested + /// without a live view. Returns nil once a coefficient exists (a blank day is just a quiet one, + /// not a missing input). Returns "No motion synced yet" when the strap has banked no motion — + /// the motion half is the blocker, not the phone half, and the countdown that names only the + /// phone half is a lie. Otherwise returns the engine's `needsMoreDays` headline. Twin of the + /// Kotlin `stepsCalibrationPrompt` guard. + static func stepsCalibrationCaption(coefficient: Double, manualCoefficient: Double, + hasBankedMotion: Bool, sampleDays: Int) -> String? { + guard coefficient <= 0, manualCoefficient <= 0 else { return nil } + if !hasBankedMotion { return String(localized: "No motion synced yet") } let status = StepsEstimateEngine.CalibrationStatus.needsMoreDays( - have: profile.stepsCalibrationSampleDays, + have: sampleDays, need: StepsEstimateEngine.minCalibrationDays) return status.headline } @@ -4218,7 +4509,7 @@ struct TodayView: View { } let hostedSessions = await repo.allSleepSessions() let hostedHabitual = await repo.habitualMidsleepSec() - let hostedMotion = await repo.sessionMotions(starts: hostedSessions.map { $0.startTs }) + let hostedMotion = await repo.sessionMotions(sessions: hostedSessions) hostedSleepModel = SleepModel.build(SleepModelInputs( days: repo.days, sleeps: repo.sleeps, @@ -4256,6 +4547,10 @@ struct TodayView: View { // toggle is OFF (the engine writes nothing) or the owner has no in-band reading. Used as a // fallback for the Blood Oxygen tile when `spo2Pct` is nil, labelled "strap estimate (unverified)". async let spo2CandidateSpark = sparkValuesExplore("spo2_candidate", source: "my-whoop", window: 14) + // Added 2026-08-24 (queue 11c follow-up) for the new Skin Temp Key Metrics tile. `exploreSeries` + // so a BLE-only strap's computed `DailyMetric.skinTempDevC` column backs the trend, same as + // `resp_rate` above — the engine writes the column, not a metricSeries point. + async let skinTempSpark = sparkValuesExplore("skin_temp", source: "my-whoop", window: 14) // `resp_rate` via `exploreSeries` so a BLE-only WHOOP 5 user's on-device computed // `DailyMetric.respRateBpm` backs the trend (the engine writes the column, not a metricSeries // point). The old `series(… source: "apple-health")` read only Apple Health's metricSeries, @@ -4273,6 +4568,7 @@ struct TodayView: View { sparks["rhr"] = await rhrSpark sparks["spo2"] = await spo2Spark sparks["spo2_candidate"] = await spo2CandidateSpark + sparks["skin_temp"] = await skinTempSpark sparks["resp_rate"] = await respRateSpark sparks["steps"] = await stepsAppleSpark // Steps prefer the strap's own @57 daily total (no metricSeries, it lives on the daily row), @@ -4479,6 +4775,8 @@ struct TodayView: View { // Rest series + the two provenance resolves, all day-keyed outputs, none consumes another's // result, so fire them concurrently and await where first used. async let restSeriesA = repo.exploreSeries(key: "sleep_performance", source: "my-whoop") + async let dayCycleSeriesA = repo.exploreSeries( + key: DayCycleIntelligenceIntegration.onsetKey, source: "my-whoop") async let recoveryResolvedA = repo.resolvedSeries(key: "recovery", source: Repository.whoopSource) async let restResolvedA = repo.resolvedSeries(key: "sleep_performance", source: Repository.whoopSource) @@ -4486,6 +4784,7 @@ struct TodayView: View { // `sleep_performance` (imported-wins), so a Bluetooth-only user sees the on-device Rest // composite and an importer sees the export's figure, exactly like the Rest detail screen. let restSeries = await restSeriesA + let dayCycleSeries = await dayCycleSeriesA let restByDay = Dictionary(restSeries.map { ($0.day, $0.value) }, uniquingKeysWith: { _, last in last }) // The Rest TILE's sparkline (#614 follow-up). The tile's number is `restScore` (the Rest composite, // 0–100) but its mini-graph used to plot raw sleep MINUTES (`sparks["sleep_total_min"]`), so the @@ -4535,11 +4834,20 @@ struct TodayView: View { // in the small hours after midnight today still starts at yesterday's midnight rather than // blanking to an empty new-calendar-day axis (#144). let dayStart = Calendar.current.startOfDay(for: selectedLogicalDay) - let windowStart = Int(dayStart.timeIntervalSince1970) - let windowEnd: Int = selectedDayOffset == 0 + let calendarStart = Int(dayStart.timeIntervalSince1970) + let calendarEnd: Int = selectedDayOffset == 0 ? Int(Date().timeIntervalSince1970) : Int((Calendar.current.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart).timeIntervalSince1970) - let hrPointsLocal = await repo.hrBuckets(from: windowStart, to: windowEnd, bucketSeconds: 300) + let nextDay = Calendar.current.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart + let nextDayKey = Repository.localDayKey(nextDay) + let cycleMarkers = dayCycleMode == .sleepOnset + ? await repo.exploreSeries(key: DayCycleIntelligenceIntegration.onsetKey, source: "my-whoop") : [] + let windowStart = cycleMarkers.last(where: { $0.day == selectedDayKey }).map { Int($0.value) } + ?? calendarStart + let windowEndExclusive = cycleMarkers.last(where: { $0.day == nextDayKey }).map { Int($0.value) } + ?? calendarEnd + let windowEndInclusive = max(windowStart, windowEndExclusive - 1) + let hrPointsLocal = await repo.hrBuckets(from: windowStart, to: windowEndInclusive, bucketSeconds: 300) .map { TrendPoint(date: Date(timeIntervalSince1970: TimeInterval($0.ts)), value: $0.bpm) } hrPoints = hrPointsLocal @@ -4549,7 +4857,7 @@ struct TodayView: View { // canonical UNION (like the HR curve / Effort above): a re-added strap banks its live step samples // under its OWN fresh id, so a read pinned to the canonical "my-whoop" would drop the icon for a // re-added strap (the #904/#908 family). nil (no classed sample) hides the icon. - let stepClassLocal = await repo.stepActivityClassLatest(from: windowStart, to: windowEnd) + let stepClassLocal = await repo.stepActivityClassLatest(from: windowStart, to: windowEndInclusive) stepActivityClassToday = stepClassLocal // #860 item 1: the launch auto-land (#605/#739 "snap to the most recent data day when today is @@ -4566,7 +4874,18 @@ struct TodayView: View { // gauge falls back to the stored row (never a fabricated value); a navigated past day clears it. let liveStrainLocal: Double? if selectedDayOffset == 0 { - let todayHr = await repo.hrSamples(from: windowStart, to: windowEnd) + let mode = DayCycleMode.persisted(UserDefaults.standard.string(forKey: DayCycleMode.storageKey)) + let cycleOnset = dayCycleSeries.last(where: { $0.day <= selectedDayKey }) + .map { Int($0.value.rounded()) } + let effortStart = mode == .sleepOnset ? (cycleOnset ?? windowStart) : windowStart + // An EXPLICIT limit, not the 8000 default: that default is chart-sized, and this read is + // whole-window. `hrSamples` is `ORDER BY ts ASC LIMIT`, so truncation drops the NEWEST rows — + // at the ~18k HR rows a real day banks, the default covered roughly the first ten hours and the + // live score silently stopped climbing after that. It failed safe (`effectiveEffort` takes the + // max, so the stored row simply won) which is why it went unnoticed. 200_000 is what every + // other whole-window HR consumer already passes. + let todayHr = await repo.hrSamples(from: effortStart, to: windowEndInclusive, + limit: 200_000) let maxHR = profile.age > 0 ? StrainScorer.tanakaHRmax(age: Double(profile.age)) : nil let restHR = displayDay?.restingHr.map(Double.init) ?? StrainScorer.defaultRestingHR liveStrainLocal = StrainScorer.strain(todayHr, maxHR: maxHR, restingHR: restHR, @@ -4578,7 +4897,7 @@ struct TodayView: View { // Pin the chart axis to the loaded window, today midnight→now, a past day the full 24h, so // a gap (e.g. a morning the strap wasn't banking) shows as empty space, not a late start. let newAxis = Date(timeIntervalSince1970: TimeInterval(windowStart)) - ... Date(timeIntervalSince1970: TimeInterval(windowEnd)) + ... Date(timeIntervalSince1970: TimeInterval(windowEndExclusive)) // #829 - keep the HR zoom VALID across reloads. The window changes on a day step (a whole new day) // and, on today, each refresh nudges the end to a fresh `now`. A day step clears the zoom so the new // day opens at full scale; a same-day end-extension keeps the user's zoom but RE-CLAMPS it into the @@ -4596,7 +4915,7 @@ struct TodayView: View { // read for a night stored as more than one block (#294). Drives the HR sleep band + the recovery // marker's wake anchor. let overlapping = await repo.allSleepSessions(days: selectedDayOffset + 2) - .filter { $0.endTs > windowStart && $0.startTs < windowEnd } + .filter { $0.endTs > windowStart && $0.startTs < windowEndExclusive } let habitualMidsleepSecLocal = await repo.habitualMidsleepSec() let sleepTodayLocal = SleepView.mainNightSpan(overlapping, habitualMidsleepSec: habitualMidsleepSecLocal) .map { span in @@ -4882,6 +5201,15 @@ struct TodayView: View { /// The Skin Temp card's value, extracted so the bimodal-column decision can be unit-tested without a /// live view. Nil (no reading anywhere in the carry chain) reads as an em-dash rather than a number. + /// The Skin Temp card's value when the surface LEADS WITH THE ABSOLUTE (#1844) — the row supplies both + /// numbers and `SkinTempDisplay.leadReading` picks, so a night that measured a real temperature shows + /// one and only a night without falls back to the signed deviation. Nil (neither number anywhere in the + /// carry chain) reads as an em-dash. The `Double?` sibling below stays for the deviation-only callers. + static func skinTempCardValue(reading: SkinTempDisplay.Reading?, fahrenheit: Bool) -> String { + guard let reading else { return "—" } + return SkinTempDisplay.formatReading(reading, fahrenheit: fahrenheit) + } + static func skinTempCardValue(_ value: Double?, fahrenheit: Bool) -> String { guard let value else { return "—" } return SkinTempDisplay.format(value, fahrenheit: fahrenheit) @@ -4953,12 +5281,9 @@ struct TodayView: View { /// show times, not the day-granularity default ("EEE d MMM"). Also formats the workout-tile caption's /// time range (#157). The "jmm" skeleton respects the device's 12-/24-hour setting (#337): "7:10 AM" /// where 12-hour is preferred, "19:10" where 24-hour is, instead of forcing one on everyone. - static let hrTimeFmt: DateFormatter = { - let f = DateFormatter() - f.locale = AppLanguage.activeLocale - f.setLocalizedDateFormatFromTemplate("jmm") - return f - }() + /// #1821: routed through AppClock so the Clock format setting reaches this label. Was a `static + /// let`, which would have frozen the reader's choice at first use until the app relaunched. + static var hrTimeFmt: DateFormatter { AppClock.hourMinuteFormatter() } } /// `.task(id:)` key combining the data refresh sequence with the selected day so a reload runs on @@ -4966,6 +5291,7 @@ struct TodayView: View { private struct TodayLoadKey: Equatable { let seq: Int let offset: Int + let dayCycleMode: String } /// #849: an in-memory snapshot of everything `loadHistoryWide()` computes: the ~40 history-wide reads + @@ -5162,12 +5488,21 @@ private struct SyncingHistoryNoteIfBackfilling: View { private struct BackfillFlagBridge: View { @EnvironmentObject private var live: LiveState @Binding var flag: Bool + /// #1164: optional mirror of `LiveState.historyPendingSync` (strap has banked records newer than our + /// frontier). Bridged through the SAME invisible leaf so a second LiveState observer isn't added to + /// the view tree (the 1 Hz flood isolation the top-of-type note describes). nil when the caller + /// doesn't need it. + @Binding var pendingSyncFlag: Bool var body: some View { Color.clear .frame(width: 0, height: 0) .accessibilityHidden(true) - .onAppear { if flag != live.backfilling { flag = live.backfilling } } + .onAppear { + if flag != live.backfilling { flag = live.backfilling } + if pendingSyncFlag != live.historyPendingSync { pendingSyncFlag = live.historyPendingSync } + } .onChangeCompat(of: live.backfilling) { now in if flag != now { flag = now } } + .onChangeCompat(of: live.historyPendingSync) { now in if pendingSyncFlag != now { pendingSyncFlag = now } } } } diff --git a/Strand/Screens/UpdatesInboxView.swift b/Strand/Screens/UpdatesInboxView.swift index ef80eb26db..d73f6b5d21 100644 --- a/Strand/Screens/UpdatesInboxView.swift +++ b/Strand/Screens/UpdatesInboxView.swift @@ -249,6 +249,8 @@ private struct UpdateRow: View { case .whatsNew: return "sparkles" case .reading: return "waveform.path.ecg" case .strapAlert: return "exclamationmark.triangle" + // Distinct from `.whatsNew`'s sparkles on purpose: this is something you have NOT got yet. + case .newVersion: return "arrow.down.circle" } } @@ -259,6 +261,7 @@ private struct UpdateRow: View { case .whatsNew: return StrandPalette.accent case .reading: return StrandPalette.restColor case .strapAlert: return StrandPalette.statusWarning + case .newVersion: return StrandPalette.accent } } diff --git a/Strand/Screens/V5PillarHosts.swift b/Strand/Screens/V5PillarHosts.swift index d96312f653..a049b4193e 100644 --- a/Strand/Screens/V5PillarHosts.swift +++ b/Strand/Screens/V5PillarHosts.swift @@ -78,23 +78,15 @@ struct RhythmHost: View { /// Read the most recent banked sleep session, pull its R-R + gravity, split into ~5-minute windows, /// gate each on stillness + resting rate, and screen it. Descriptive stats only — never a verdict. private func load() async { - guard let store = await repo.storeHandle(), - let lastSleep = (await repo.allSleepSessions(days: 14)).last else { return } + guard let lastSleep = (await repo.allSleepSessions(days: 14)).last else { return } let lo = lastSleep.effectiveStartTs let hi = lastSleep.endTs guard hi > lo else { return } - let rr = (try? await store.rrIntervals(deviceId: repo.deviceId, from: lo, to: hi, limit: 200_000)) ?? [] - // BLE-only users have their night under the computed source; fall back to it when the imported - // device yields nothing. Gravity MUST be read from the SAME source id as the R-R (#1360): otherwise - // a fall-back night reads its stillness from the wrong device and every window fails the motion - // gate, which the empty-state diagnosis below would then misread as "no stillness signal at all". - let usedFallback = rr.isEmpty - let sourceId = usedFallback ? repo.deviceId + "-noop" : repo.deviceId - let rrRows = usedFallback - ? ((try? await store.rrIntervals(deviceId: sourceId, from: lo, to: hi, limit: 200_000)) ?? []) - : rr + // The selected night can belong to a previous/archived strap. Resolve the same all-WHOOP worn + // timeline used by Sleep rather than pinning its raw physiology to whichever strap is active now. + let rrRows = await repo.rrIntervals(from: lo, to: hi, limit: 200_000) guard !rrRows.isEmpty else { return } - let grav = (try? await store.gravitySamples(deviceId: sourceId, from: lo, to: hi, limit: 200_000)) ?? [] + let grav = await repo.gravitySamplesUnion(from: lo, to: hi, limit: 200_000) // Window the night into 5-minute slices; a slice is "still" when its gravity variance is small. let windowSec = 5 * 60 diff --git a/Strand/Screens/VitalSignsSummary.swift b/Strand/Screens/VitalSignsSummary.swift index 0cb30a375b..a56dc67a84 100644 --- a/Strand/Screens/VitalSignsSummary.swift +++ b/Strand/Screens/VitalSignsSummary.swift @@ -27,6 +27,14 @@ struct BodyVitalReading: Identifiable { /// strap's own capture is known-unreliable for it (e.g. a WHOOP 4.0 R-R over-count contaminating HRV). /// nil = no caveat. Defaulted so existing call sites keep compiling unchanged. var caveat: String? = nil + /// A second reading shown with the caption, under the headline value (#1636). + /// + /// Distinct from `caveat`, which says the value is unreliable; this one says what the value means. + /// Skin temperature is the case: the absolute leads, and the deviation it was derived from is what + /// makes it legible — "+0.2" says nothing without an anchor, and 34.6 °C says little without + /// knowing it runs high for you. Pure formatted data (a number and a unit), never a sentence. + /// Defaulted so existing call sites keep compiling unchanged. + var secondary: String? = nil var id: String { key } @@ -48,7 +56,9 @@ struct BodyVitalReading: Identifiable { /// line when nothing resolved, so an empty tile still says why instead of a bare dash. var stateCaption: String { guard let day else { return missingCaption } - var parts = [Self.dayLabel(day)] + // #1636: the secondary reading leads, so it sits directly under the headline value. + var parts = secondary.map { [$0] } ?? [] + parts.append(Self.dayLabel(day)) if let sourceText = Self.sourceLabel(source, key: key) { parts.append(sourceText) } @@ -123,7 +133,10 @@ enum BodyVitalSigns { temperatureUnit: TemperatureUnit, now: Date = Date(), spo2CandidateByDay: [String: Double] = [:], - hrvOverCountByDay: [String: Double] = [:]) -> [BodyVitalReading] { + hrvOverCountByDay: [String: Double] = [:], + // #1846: the Settings lead-with choice, so this tile agrees with Today and the + // detail screen. A setting that reaches two of three surfaces is worse than none. + skinTempPreferred: SkinTempDisplay.Kind = .absolute) -> [BodyVitalReading] { let logicalDay = logicalDayKey(now) // Resolve one metric to a per-day series, taking the FIRST source (by precedence) that carries @@ -183,6 +196,10 @@ enum BodyVitalSigns { let rhrPoints = points(key: "rhr") { $0.restingHr.map(Double.init) } let hrvPoints = points(key: "hrv", \.avgHrv) let skinPoints = points(key: "skin", \.skinTempDevC) + // #1636: the night's ABSOLUTE, when the strap measured one. Nights scored before that column + // shipped carry only the deviation and refill on the next scoring pass, so this is empty until + // then and everything below falls through to the deviation-led behaviour unchanged. + let skinAbsPoints = points(key: "skin", \.skinTempC) let respRow = latest(respPoints) // #103/queue-11a: fall back to the spo2_candidate mean when no calibrated spo2Pct exists. The @@ -193,7 +210,7 @@ enum BodyVitalSigns { let spo2rawRow = latest(spo2rawPoints) let rhrRow = latest(rhrPoints) let hrvRow = latest(hrvPoints) - let skinRow = latest(skinPoints) + let skinRowDeviation = latest(skinPoints) // #1118: mark HRV "unverified" when this night's in-sleep R-R was over-counted — the WHOOP 4.0 // two-optical-channel artifact that inflates R-R and contaminates RMSSD, so NOOP's HRV won't match // WHOOP until the de-dup fix lands. The flag is written only for NOOP's OWN measured capture (an @@ -214,13 +231,34 @@ enum BodyVitalSigns { // Skin temp is bimodal: CSV imports store ABSOLUTE °C, the on-device pipeline a ±°C DEVIATION — // partition the history to the displayed value's kind and pick the matching config + population // fallback (±0.6 °C mirrors the illness watch's flag threshold). + // + // #1636: resolve the DISPLAYED night first — the freshest that carries either reading — then + // lead with its absolute if it has one. Asking "does the row I am already showing have an + // absolute?" is what keeps the tile from silently stepping back to an older night: an + // import-only night has a deviation and no absolute, and a CALIBRATING night has the reverse + // (`recomputeSkinTempDev` returns nil until the baseline is usable, while the absolute is + // already measured — those wearers read "needs ~4 worn nights" today with a real temperature + // sitting unshown behind it). + let skinAbsCandidate = latest(skinAbsPoints) + let skinRowDay: String? = [skinRowDeviation?.day, skinAbsCandidate?.day].compactMap { $0 }.max() + let skinAbsCandidateOnDay = skinAbsCandidate.flatMap { $0.day == skinRowDay ? $0 : nil } + // #1846: the same `leadReading` rule the other surfaces use — the preference picks which number is + // tried first, the other stays the fallback, so choosing one never empties the tile. + let skinLeadsAbsolute = SkinTempDisplay.leadReading(absC: skinAbsCandidateOnDay?.value, + devC: skinRowDeviation?.value, + prefer: skinTempPreferred)?.kind == .absolute + let skinAbsRow = skinLeadsAbsolute ? skinAbsCandidateOnDay : nil + let skinRow = skinAbsRow ?? skinRowDeviation ?? skinAbsCandidateOnDay let skin = skinRow?.value - let skinIsAbsolute = skin.map(VitalBands.isAbsoluteSkinTemp) ?? true + let skinIsAbsolute = skinAbsRow != nil || (skin.map(VitalBands.isAbsoluteSkinTemp) ?? true) + // The series the tile is actually showing — banding and the sparkline must both read from it, or + // an absolute would be scored against a history of deviations (#1636). + let skinSeries = skinAbsRow != nil ? skinAbsPoints : skinPoints let skinResult: VitalBands.Result if let skin { skinResult = VitalBands.band( value: skin, - history: VitalBands.skinTempHistory(matching: skin, in: history(before: skinRow?.day, skinPoints)), + history: VitalBands.skinTempHistory(matching: skin, in: history(before: skinRow?.day, skinSeries)), populationRange: skinIsAbsolute ? 33...36 : (-0.6)...0.6, cfg: skinIsAbsolute ? Baselines.metricCfg["skin_temp"]! : VitalBands.skinTempDeviationCfg ) @@ -239,6 +277,15 @@ enum BodyVitalSigns { let skinFormat: (Double) -> String = { c in SkinTempDisplay.numberString(c, kind: skinKind, fahrenheit: fahrenheit, decimals: 1) } + // #1636: the deviation for THE DISPLAYED NIGHT — not the freshest one anywhere. A calibrating + // night carries an absolute and no deviation, and reaching for the latest deviation there would + // print a previous night's number under tonight's temperature. + let skinSecondary: String? = skinAbsRow == nil ? nil + : skinPoints.last(where: { $0.day == skinRow?.day }).map { dev in + let n = SkinTempDisplay.numberString(dev.value, kind: .deviation, + fahrenheit: fahrenheit, decimals: 1) + return "\(n) \(SkinTempDisplay.unitSymbol(kind: .deviation, fahrenheit: fahrenheit))" + } return [ BodyVitalReading( @@ -371,7 +418,10 @@ enum BodyVitalSigns { missingCaption: String(localized: "No nightly skin-temp yet — needs ~4 worn nights (or import a WHOOP CSV)"), // Keep the trail on the displayed value's kind — absolute °C and ±deviation must not // mix on one sparkline (matches the banding partition above). - sparkline: trail(skinPoints.filter { VitalBands.isAbsoluteSkinTemp($0.value) == skinIsAbsolute }) + sparkline: trail(skinSeries.filter { VitalBands.isAbsoluteSkinTemp($0.value) == skinIsAbsolute }), + // #1636: the deviation this absolute was derived from, shown beneath it. Only when the + // headline IS the absolute — on a deviation-led tile it would just repeat the value. + secondary: skinSecondary ), ] } diff --git a/Strand/Screens/WorkoutDetailView.swift b/Strand/Screens/WorkoutDetailView.swift index 7a7bb1550a..373a874e97 100644 --- a/Strand/Screens/WorkoutDetailView.swift +++ b/Strand/Screens/WorkoutDetailView.swift @@ -35,7 +35,12 @@ struct WorkoutDetailView: View { @Environment(\.dismiss) private var dismiss @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue - private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } + @AppStorage(UnitPrefs.distanceSystemKey) private var distanceSystemRaw = "" + private var distanceUnitSystem: UnitSystem { + UnitPrefs.resolveDistance( + system: UnitSystem(rawValue: unitSystemRaw) ?? .metric, + override: distanceSystemRaw) + } @AppStorage(UnitPrefs.effortScaleKey) private var effortScaleRaw = EffortScale.hundred.rawValue private var effortScale: EffortScale { UnitPrefs.resolveEffortScale(effortScaleRaw) } @@ -280,7 +285,7 @@ struct WorkoutDetailView: View { @ViewBuilder private var routeCard: some View { if route.count >= 2 { VStack(alignment: .leading, spacing: NoopMetrics.gap) { - SectionHeader("Route", overline: "Recorded on device", + SectionHeader("Route", overline: routeOriginLabel, trailing: distanceLabel(row.distanceM)) NoopCard(padding: 0, tint: StrandPalette.effortColor) { VStack(alignment: .leading, spacing: 0) { @@ -298,7 +303,7 @@ struct WorkoutDetailView: View { .padding(NoopMetrics.cardPadding) } } - Text("Your GPS route for this session, recorded and stored on your device. Nothing leaves your phone.") + Text(routeDescription) .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) @@ -361,12 +366,7 @@ struct WorkoutDetailView: View { let secs = row.durationS ?? Double(row.endTs - row.startTs) guard secs > 0 else { return "–" } let km = m / 1000.0 - let (perUnit, label): (Double, String) = unitSystem == .imperial - ? (km * UnitFormatter.milesPerKilometer, "/mi") - : (km, "/km") - guard perUnit > 0 else { return "–" } - let secsPerUnit = Int((secs / perUnit).rounded()) - return "\(secsPerUnit / 60):\(String(format: "%02d", secsPerUnit % 60)) \(label)" + return UnitFormatter.paceFromSecPerKm(secs / km, system: distanceUnitSystem) } private var routeAccessibilityLabel: String { @@ -374,6 +374,34 @@ struct WorkoutDetailView: View { return String(localized: "Map of your \(WorkoutSource.displaySport(row.sport)) route, \(dist).") } + /// #1205: the route card's overline and description must be honest about where the route came + /// from. An on-device recorded route says "Recorded on device"; an imported route (Apple Health + /// or Health Connect) says "Imported from Apple Health" / "Imported" so the user is not told + /// their phone recorded GPS data that actually came from another app. + private var routeOriginLabel: LocalizedStringKey { + switch WorkoutSource.classify(row.source) { + // "Imported" rather than naming the app: every string here is one the catalog already carries in + // all nine locales, so the honesty fix ships translated on day one. A source-specific variant + // ("Imported from Apple Health") would be a NEW key, and nothing catches a missing entry: the i18n + // audit checks locale coverage OF catalog entries, not that a `String(localized:)` literal has one. + // It would have read English on every non-English device while the gate stayed green. + case .apple, .whoop, .lifting, .activityFile: return "Imported" + case .detected, .manual: return "Recorded on device" + } + } + + private var routeDescription: String { + switch WorkoutSource.classify(row.source) { + // Same rule as the overline: both of these are existing catalog keys with all nine locales. The + // imported line carries the privacy claim without the "recorded on your device" the original + // string opens with, which is the part that was untrue for a route another app collected. + case .apple, .whoop, .lifting, .activityFile: + return String(localized: "This stays on your device. It is never uploaded, never synced, never shared.") + case .detected, .manual: + return String(localized: "Your GPS route for this session, recorded and stored on your device. Nothing leaves your phone.") + } + } + // MARK: - HR curve @ViewBuilder private var hrCurveCard: some View { @@ -573,18 +601,12 @@ struct WorkoutDetailView: View { f.dateFormat = "EEEE d MMM yyyy" return f }() - private static let timeFmt: DateFormatter = { - let f = DateFormatter() - f.locale = AppLanguage.activeLocale - f.setLocalizedDateFormatFromTemplate("jmm") - return f - }() - private static let tooltipTime: DateFormatter = { - let f = DateFormatter() - f.locale = AppLanguage.activeLocale - f.setLocalizedDateFormatFromTemplate("jmm") - return f - }() + /// #1821: routed through AppClock so the Clock format setting reaches this label. Was a `static + /// let`, which would have frozen the reader's choice at first use until the app relaunched. + private static var timeFmt: DateFormatter { AppClock.hourMinuteFormatter() } + /// #1821: routed through AppClock so the Clock format setting reaches this label. Was a `static + /// let`, which would have frozen the reader's choice at first use until the app relaunched. + private static var tooltipTime: DateFormatter { AppClock.hourMinuteFormatter() } private func dateLabel(_ ts: Int) -> String { Self.dateFmt.string(from: Date(timeIntervalSince1970: TimeInterval(ts))) @@ -604,7 +626,7 @@ struct WorkoutDetailView: View { } private func distanceLabel(_ m: Double?) -> String { guard let m, m > 0 else { return "–" } - return UnitFormatter.distanceFromMeters(m, system: unitSystem) + return UnitFormatter.distanceFromMeters(m, system: distanceUnitSystem) } private func grouped(_ v: Double) -> String { Self.intFmt.string(from: NSNumber(value: Int(v.rounded()))) ?? "\(Int(v.rounded()))" diff --git a/Strand/Screens/WorkoutsView.swift b/Strand/Screens/WorkoutsView.swift index 84f3bbd289..d20eea588f 100644 --- a/Strand/Screens/WorkoutsView.swift +++ b/Strand/Screens/WorkoutsView.swift @@ -33,10 +33,14 @@ struct WorkoutsView: View { @State private var showLiveWorkout = false @State private var showStartSport = false - // Imperial/Metric display preference (D#103). Workout distances are stored in metres; the toggle - // re-labels them to miles/yards. Display-only — nothing on disk changes. + // Exercise-distance preference (#1913). Unset follows the original combined preference. @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue - private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } + @AppStorage(UnitPrefs.distanceSystemKey) private var distanceSystemRaw = "" + private var distanceUnitSystem: UnitSystem { + UnitPrefs.resolveDistance( + system: UnitSystem(rawValue: unitSystemRaw) ?? .metric, + override: distanceSystemRaw) + } // Effort display scale (#268) — drives the effort hero's read-out. Display-only. @AppStorage(UnitPrefs.effortScaleKey) private var effortScaleRaw = EffortScale.hundred.rawValue @@ -972,7 +976,7 @@ struct WorkoutsView: View { caption: "kcal", accent: StrandPalette.metricAmber) StatTile(label: "Total Distance", - value: distancesM.isEmpty ? "–" : UnitFormatter.distanceFromKilometers(totalKmRaw, system: unitSystem), + value: distancesM.isEmpty ? "–" : UnitFormatter.distanceFromKilometers(totalKmRaw, system: distanceUnitSystem), caption: String(localized: "covered"), accent: StrandPalette.metricCyan) StatTile(label: "Most Active", @@ -1735,12 +1739,9 @@ struct WorkoutsView: View { // The "jmm" skeleton respects the device's 12-/24-hour setting (#337): "4:34 PM" where 12-hour is // preferred, "16:34" where 24-hour is — instead of forcing 24-hour on everyone (matches TodayView). - private static let timeFmt: DateFormatter = { - let f = DateFormatter() - f.locale = AppLanguage.activeLocale - f.setLocalizedDateFormatFromTemplate("jmm") - return f - }() + /// #1821: routed through AppClock so the Clock format setting reaches this label. Was a `static + /// let`, which would have frozen the reader's choice at first use until the app relaunched. + private static var timeFmt: DateFormatter { AppClock.hourMinuteFormatter() } private func dateLabel(_ ts: Int) -> String { Self.dateFmt.string(from: Date(timeIntervalSince1970: TimeInterval(ts))) @@ -1772,7 +1773,7 @@ struct WorkoutsView: View { private func distanceLabel(_ m: Double?) -> String { guard let m, m > 0 else { return "–" } - return UnitFormatter.distanceFromMeters(m, system: unitSystem) + return UnitFormatter.distanceFromMeters(m, system: distanceUnitSystem) } private func oneDecimal(_ v: Double) -> String { String(format: "%.1f", v) } diff --git a/Strand/Screens/XiaomiBandView.swift b/Strand/Screens/XiaomiBandView.swift index fa0bef23ec..e98b4e77c7 100644 --- a/Strand/Screens/XiaomiBandView.swift +++ b/Strand/Screens/XiaomiBandView.swift @@ -19,6 +19,13 @@ private struct ChartWidthKey: PreferenceKey { struct XiaomiBandView: View { @EnvironmentObject var repo: Repository + @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue + @AppStorage(UnitPrefs.distanceSystemKey) private var distanceSystemRaw = "" + private var distanceUnitSystem: UnitSystem { + UnitPrefs.resolveDistance( + system: UnitSystem(rawValue: unitSystemRaw) ?? .metric, + override: distanceSystemRaw) + } /// Per-source partition key — matches `XiaomiImporter.deviceId`. private static let source = "xiaomi-band" @@ -165,7 +172,9 @@ struct XiaomiBandView: View { .chart("Sleep score", "sleep_score", accentGradient, 0...100, { "\(Int($0.rounded()))" }), .header("Activity & Energy", "Movement"), .chart("Steps", "steps", cyanGradient, 0...12000, { intString($0) }), - .chart("Distance", "distance_m", cyanGradient, 0...10000, { String(format: "%.2f km", $0 / 1000) }), + .chart("Distance", "distance_m", cyanGradient, 0...10000, { + UnitFormatter.distanceFromMeters($0, system: distanceUnitSystem) + }), .chart("Active energy", "energy_kcal", amberGradient, 0...1000, { "\(intString($0)) kcal" }), .chart("Intensity minutes", "intensity_min", amberGradient, 0...120, { "\(Int($0.rounded())) min" }), .header("Wellbeing", "Body energy"), diff --git a/Strand/System/AppChangelog.swift b/Strand/System/AppChangelog.swift index 28202c47c9..7b42f96893 100644 --- a/Strand/System/AppChangelog.swift +++ b/Strand/System/AppChangelog.swift @@ -7,7 +7,7 @@ enum AppChangelog { /// Bump this when you add a release below. The "What's New" sheet shows automatically when the /// stored last-seen version is behind this. (Decoupled from the bundle version on purpose.) - static let currentVersion = "10.6.0" + static let currentVersion = "11.1.0" struct Release: Identifiable { let version: String @@ -19,6 +19,35 @@ enum AppChangelog { /// Newest first. static let releases: [Release] = [ + Release( + version: "11.1.0", + title: "Choose a 12-hour clock, sleep from straps that bank no motion, and a strap log that stops guessing", + date: "September 2026", + items: [ + "**Pick the clock you read times in (#1821).** Settings → Appearance now offers System, 12-hour or 24-hour. It defaults to System, so nothing changes unless you ask — and System now means your phone's own 24-hour switch, which NOOP was previously ignoring in favour of your region's default. A reader in a 24-hour country who prefers 12-hour had no way to say so.", + "**Sleep from a strap that records no motion (#1801).** A WHOOP 5/MG that never pairs banks no movement data, and sleep detection is built on stillness — so those nights scored as nothing at all. NOOP can now find a night from heart rate alone and stage it. It is display-only by design: an HR-only night never feeds your resting heart rate or HRV baselines, because it has not earned that.", + "**A charged strap is no longer told to charge (#1818).** The 1970/71 clock warning offered one remedy regardless of battery, so people at 100% were sent round a loop they had already run. It now says something true for a strap that is already charged, and asks for the log that can actually explain it.", + "**The coach can see your sleep stages (#1816).** It was answering that it had no access to them, because deep, REM and light minutes were never in what it was given. They are now, along with sleep efficiency.", + "**Back up a database larger than NOOP would restore (#1807).** Export warns when an archive is past the 2 GiB restore ceiling instead of writing it silently, and a restore can be allowed through rather than refused outright.", + "**Home-screen widget corrections (#1795, thanks @Sneheth; #1799).** The heart-rate and HRV values sat under each other's icons on Apple. Both platforms' widgets now also read properly aloud, with the value spoken rather than the raw number.", + "**Live workout no longer stacks two timers (#1814), and the steps card stops asking for the half you already gave it (#1815).**", + "**A strap log that reports rather than assumes (#1809, thanks @supremesynergy; #1823).** Every disconnect now records how long the link held and whether the strap sent anything at all, and the clock exchange quotes what the strap actually answered. NOOP used to write \"clock synced\" the moment it queued the write, before any reply existed — so a log could insist the clock was set while the screen said 1970/71.", + "**Oura rings with no name, and Android's heart-rate logging (#1797, thanks @pipiche38; #1796, thanks @kvnloo).**", + ] + ), + Release( + version: "11.0.0", + title: "A WHOOP 5 that stays connected, your body clock on the Sleep screen, and a Journal that knows No from nothing", + date: "September 2026", + items: [ + "**The WHOOP 5.0 and MG stop dropping every few seconds (#1635, thanks @Zebsi235).** A handshake the strap never answers was knocking a perfectly good link down about every five seconds, all day. NOOP now recognises a strap that will not complete that handshake, stops attempting it, and holds the link instead — live heart rate keeps streaming rather than restarting forever. Tapping Connect costs one reconnect now, not five.", + "**Your body clock, on the Sleep screen (#1722, #1723, #1729, #1733).** A 24-hour dial showing when your body actually wants to sleep, and a chronotype read from your own nights rather than a questionnaire. It says what it used and refuses to guess when it cannot see enough.", + "**Nights read straighter (#1717, #1734, #1741, #1755, #1782).** A night that only partly downloaded is no longer scored as if it were whole. Sleep that arrives in fragments is stitched back together when your heart rate says you stayed asleep through the gap. And sleep debt is now a number you can act on tonight.", + "**A day you never logged is not a day you answered No (#322).** Journal insights compared days you ticked against every other day — including the ones you simply did not open the app. They now compare Yes days against No days only, so an untracked week stops quietly counting against you.", + "**NOOP tells you when there is a new version (#1674, #1675).** Both platforms now notice a release and say so, on by default, checked directly against the release feed with nothing else sent.", + "**Health Connect asks for one category at a time (#1509, thanks @kavemang).** Recovery, Activity and Body composition are chosen before Android's prompt appears, so you grant what you meant to. Existing installs keep exactly what they already had.", + ] + ), Release( version: "10.6.0", title: "An Effort scale you choose, a ring that gets to sleep, and far fewer wasted re-scores", diff --git a/Strand/System/CoachBriefScheduler.swift b/Strand/System/CoachBriefScheduler.swift new file mode 100644 index 0000000000..df4ba40bc7 --- /dev/null +++ b/Strand/System/CoachBriefScheduler.swift @@ -0,0 +1,370 @@ +import Foundation +import UserNotifications +#if os(iOS) +import BackgroundTasks +import WidgetKit +#endif + +/// PRD-K5: a user-scheduled, LOCAL notification that delivers a one-line morning coaching brief, +/// generated on-device via the user's already-configured Coach provider, at a chosen time each day. +/// Tap → opens Coach with the full brief. No push server, no cloud — the network call is the SAME +/// bring-your-own-key request Coach already makes on every send, just user-armed on a daily timer +/// instead of triggered by a tap. Default OFF, like every NOOP automation. +/// +/// Cross-platform (macOS + iOS), same architecture as `ScheduledDebugExport`: +/// - **macOS** — the app is usually running; a foreground `DispatchSourceTimer` fires at the chosen +/// minute, and `catchUpIfDue` covers the time having passed while the app wasn't open. +/// - **iOS** — a sideloaded, backgrounded app can't guarantee a wake at the exact minute. A +/// `BGAppRefreshTaskRequest` is submitted for *no earlier than* the chosen time; iOS decides when it +/// actually runs. If the slot is missed entirely (app never woken), the next foreground open's +/// `activateIfEnabled` catches up and generates then instead — same honest "best-effort" posture +/// `ScheduledDebugExport`'s Settings copy already uses. +/// +/// Triple-gated network call: `enabled` (this feature, default OFF) + a saved Coach key + the existing +/// `dataConsent`. `generateBrief` is `AICoachEngine.generateBrief()`, itself gated on both of those; this +/// scheduler additionally never marks a day "done" on a failed generation, so a missing key/consent (or +/// a transient network failure) simply retries next wake rather than going permanently silent. +@MainActor +enum CoachBriefScheduler { + + // MARK: - Persisted settings (own keys; mirrors ScheduledDebugExport's shape) + + private enum K { + static let enabled = "coachBrief.enabled" // master enable; default OFF + static let time = "coachBrief.timeMinutes" // minutes since local midnight; default 07:00 + static let lastRun = "coachBrief.lastRunDayKey" // yyyy-MM-dd of the last GENERATED brief (dedup) + static let storedBrief = "coachBrief.storedText" // the full brief, for the notification tap-through + static let hasUnconsumed = "coachBrief.hasUnconsumedBrief" + // K10: the brief text mirrored into the App Group so the widget extension (separate process) + // can read it. The app's own UserDefaults.standard isn't visible to the widget. + static let widgetBriefKey = "coachBrief.widgetText" + static let widgetBriefDateKey = "coachBrief.widgetDate" + } + + /// 07:00 — a brief waiting when you check your phone (matches `ScheduledDebugExport`'s default). + static let defaultTimeMinutes = 7 * 60 + private static let minutesPerDay = 24 * 60 + + /// Category id the posted notification carries, so `NotificationPresenter` can recognise a brief tap + /// (as opposed to any other local notification) and route it to Coach. + static let notificationCategoryId = "coach-brief" + private static let requestIdPrefix = "coach-brief-" + + static var isEnabled: Bool { UserDefaults.standard.bool(forKey: K.enabled) } + + /// Time-of-day to generate, minutes since local midnight. Clamped to a valid minute. Default 07:00. + static var timeMinutes: Int { + let v = UserDefaults.standard.object(forKey: K.time) as? Int ?? defaultTimeMinutes + return min(max(v, 0), minutesPerDay - 1) + } + + /// The most recently generated full brief text (for the notification tap-through), or nil before the + /// first successful generation. + static var storedBrief: String? { UserDefaults.standard.string(forKey: K.storedBrief) } + + /// True when a generated brief hasn't yet been surfaced in the Coach transcript. Set right after a + /// successful generation; cleared by `consumeStoredBrief()`. + static var hasUnconsumedBrief: Bool { UserDefaults.standard.bool(forKey: K.hasUnconsumed) } + + /// Coach reads this once (on appear, with an empty transcript) to surface the stored brief as its + /// first message without re-generating or re-sending anything. Returns nil (and leaves state + /// untouched) when there's nothing unconsumed, so a normal open is unaffected. + static func consumeStoredBrief() -> String? { + guard hasUnconsumedBrief, let text = storedBrief else { return nil } + UserDefaults.standard.set(false, forKey: K.hasUnconsumed) + return text + } + + // MARK: - K10: Widget-facing API (App Group, iOS only) + + #if os(iOS) + /// The stored brief text for the widget (App Group), or nil before the first successful generation. + /// The widget extension reads this — it can't see the app's `UserDefaults.standard`. + static var widgetBriefText: String? { + UserDefaults(suiteName: WidgetSnapshot.suiteName)?.string(forKey: K.widgetBriefKey) + } + + /// Wall-clock date the widget brief was generated, so the widget can show honest staleness. + static var widgetBriefDate: Date? { + UserDefaults(suiteName: WidgetSnapshot.suiteName)?.object(forKey: K.widgetBriefDateKey) as? Date + } + + /// Publish (or clear) the brief into the App Group so the widget extension can read it. Called + /// from `catchUpIfDue` / `generateNow` after a successful generation, and from `setEnabled(false)` + /// to clear the widget when the user turns the feature off. + static func publishToWidget(_ text: String?) { + guard let defaults = UserDefaults(suiteName: WidgetSnapshot.suiteName) else { return } + if let text { + defaults.set(text, forKey: K.widgetBriefKey) + defaults.set(Date(), forKey: K.widgetBriefDateKey) + } else { + defaults.removeObject(forKey: K.widgetBriefKey) + defaults.removeObject(forKey: K.widgetBriefDateKey) + } + // Nudge WidgetKit to reload the coach-brief widget's timeline so it picks up the new text + // without waiting for the next scheduled refresh. The kind string must match the widget's + // `kind` property (see CoachBriefWidget.swift in StrandiOSWidgets). + WidgetCenter.shared.reloadTimelines(ofKind: Self.widgetKind) + } + #endif + + /// The WidgetKit kind string for the coach-brief widget. Must match the `kind` property on + /// `CoachBriefWidget` in `StrandiOSWidgets/`. Declared here so `publishToWidget` can reload + /// timelines without importing the widget extension's type (the app target can't see it). + static let widgetKind = "CoachBriefWidget" + + // MARK: - Enable / configure (Settings calls these) + + /// The result of enabling the schedule — mirrors `WindDownNudge.EnableOutcome` so the UI can revert + /// the toggle and point at Settings on denial instead of persisting a dead "on" state. + enum EnableOutcome { case scheduled, denied, off } + + /// Enable/disable and (re)schedule. Enabling gates on notification authorization first (asks once if + /// undetermined; reports `.denied` rather than silently scheduling something the OS will never + /// deliver). `completion` always runs on the main actor. + static func setEnabled(_ on: Bool, + generateBrief: @escaping () async -> String?, + completion: (@MainActor (EnableOutcome) -> Void)? = nil) { + guard on else { + UserDefaults.standard.set(false, forKey: K.enabled) + cancel() + #if os(iOS) + publishToWidget(nil) // K10: clear the widget when the feature is turned off + #endif + completion?(.off) + return + } + UNUserNotificationCenter.current().getNotificationSettings { settings in + Task { @MainActor in + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + UserDefaults.standard.set(true, forKey: K.enabled) + scheduleNext(generateBrief: generateBrief) + completion?(.scheduled) + case .notDetermined: + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in + Task { @MainActor in + if granted { + UserDefaults.standard.set(true, forKey: K.enabled) + scheduleNext(generateBrief: generateBrief) + completion?(.scheduled) + } else { + UserDefaults.standard.set(false, forKey: K.enabled) + completion?(.denied) + } + } + } + default: + UserDefaults.standard.set(false, forKey: K.enabled) + completion?(.denied) + } + } + } + } + + /// Update the time-of-day and reschedule so the new time takes effect immediately. + static func setTimeMinutes(_ minutes: Int, generateBrief: @escaping () async -> String?) { + UserDefaults.standard.set(min(max(minutes, 0), minutesPerDay - 1), forKey: K.time) + if isEnabled { scheduleNext(generateBrief: generateBrief) } + } + + /// Call whenever Coach (or its settings) appears so the schedule self-heals (re-arms the macOS timer + /// after a relaunch, re-submits the iOS request) and a slot missed while the app wasn't open still + /// generates once. No-op when the feature is off. + static func activateIfEnabled(generateBrief: @escaping () async -> String?) { + guard isEnabled else { return } + scheduleNext(generateBrief: generateBrief) + Task { await catchUpIfDue(generateBrief: generateBrief) } + } + + /// The explicit "Generate now" button: always generates and stores, ignoring the once-per-day dedup, + /// so a tap produces a fresh brief there and then. Does NOT post a notification (the user is already + /// looking at Coach) and does NOT touch `lastRun`, so today's scheduled slot still fires normally. + /// Returns the brief text, or nil on failure (no key/consent/network) — the caller surfaces that. + @discardableResult + static func generateNow(generateBrief: () async -> String?) async -> String? { + await generateBrief() + } + + // MARK: - Due check + generation + + /// If today's brief is due (we're at/after the chosen time and haven't generated today), generate it + /// once: store the text, mark today done, and post the notification. Covers macOS launches where the + /// time passed while the app wasn't open, and the iOS foreground/BGTask paths. + /// + /// Returns whether this call can be considered successful: `true` when there was nothing to do + /// (feature off, not yet time, or already ran today) or generation succeeded; `false` only when + /// generation was attempted and failed (so the iOS BGTask handler can report the real outcome and + /// retry on the next wake instead of marking the day done on a failure). + @discardableResult + static func catchUpIfDue(generateBrief: () async -> String?) async -> Bool { + guard isEnabled else { return true } + let now = Date() + let cal = Calendar.current + let comps = cal.dateComponents([.hour, .minute], from: now) + let nowMinutes = (comps.hour ?? 0) * 60 + (comps.minute ?? 0) + guard nowMinutes >= timeMinutes else { return true } // not yet time today + guard UserDefaults.standard.string(forKey: K.lastRun) != dayKey(now) else { return true } // already ran today + + guard let text = await generateBrief() else { + // No key/consent/network, or the provider returned nothing. Never mark the day done, so the + // next wake (BGTask retry, or the next foreground open) tries again. Post a low-key retry + // notification rather than silently doing nothing — the PRD's "unavailable, tap to retry". + postNotification(title: String(localized: "Coach brief unavailable"), + body: String(localized: "Couldn't generate today's brief. Tap to try again in Coach."), + isRetry: true) + return false + } + + UserDefaults.standard.set(dayKey(now), forKey: K.lastRun) + UserDefaults.standard.set(text, forKey: K.storedBrief) + UserDefaults.standard.set(true, forKey: K.hasUnconsumed) + #if os(iOS) + publishToWidget(text) // K10: mirror into the App Group for the widget + #endif + postNotification(title: String(localized: "Today's coaching brief"), + body: oneLineSummary(from: text)) + return true + } + + /// Pure: the notification body is the brief's first non-empty line, trimmed and length-capped so it + /// fits a notification banner. Unit-testable without any I/O. + static func oneLineSummary(from brief: String, maxLength: Int = 120) -> String { + let firstLine = brief + .split(separator: "\n", omittingEmptySubsequences: true) + .first + .map(String.init) ?? brief + let trimmed = firstLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count > maxLength else { return trimmed } + let cut = trimmed.index(trimmed.startIndex, offsetBy: maxLength) + return trimmed[.. String { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = "yyyy-MM-dd" + return f.string(from: date) + } + + /// Seconds from now until the next wall-clock occurrence of `minuteOfDay` (today if still ahead, else + /// tomorrow). Mirrors `ScheduledDebugExport.secondsToNextOccurrence`. + private static func secondsToNextOccurrence(_ minuteOfDay: Int, now: Date = Date()) -> TimeInterval { + let cal = Calendar.current + var comps = cal.dateComponents([.year, .month, .day], from: now) + comps.hour = minuteOfDay / 60 + comps.minute = minuteOfDay % 60 + comps.second = 0 + var target = cal.date(from: comps) ?? now + if target <= now { target = cal.date(byAdding: .day, value: 1, to: target) ?? target } + return max(1, target.timeIntervalSince(now)) + } + + // MARK: - Scheduling + + private static var macTimer: DispatchSourceTimer? + + /// (Re)arm the next occurrence. macOS uses a foreground `DispatchSourceTimer`; iOS submits a + /// background-refresh request. Both target the next wall-clock occurrence of `timeMinutes`. + private static func scheduleNext(generateBrief: @escaping () async -> String?) { + #if os(macOS) + macTimer?.cancel() + let timer = DispatchSource.makeTimerSource(queue: .main) + let delay = secondsToNextOccurrence(timeMinutes) + timer.schedule(deadline: .now() + delay) + timer.setEventHandler { + Task { @MainActor in + guard isEnabled else { return } + _ = await catchUpIfDue(generateBrief: generateBrief) + scheduleNext(generateBrief: generateBrief) + } + } + timer.resume() + macTimer = timer + #elseif os(iOS) + submitBackgroundRequest() + #endif + } + + /// Cancel any armed schedule. + private static func cancel() { + #if os(macOS) + macTimer?.cancel() + macTimer = nil + #elseif os(iOS) + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: bgTaskIdentifier) + #endif + } + + // MARK: - iOS background task plumbing + + #if os(iOS) + /// iOS BGTask identifier, derived from the running bundle id so it tracks `BUNDLE_ID_PREFIX` and + /// matches the iOS target's `BGTaskSchedulerPermittedIdentifiers` (Info.plist / project.yml). + static let bgTaskIdentifier = (Bundle.main.bundleIdentifier ?? "com.noopapp.noop") + ".coachbrief" + + /// Register the BGTask handler. MUST be called from the app's launch (before launch finishes) — call + /// this from `StrandiOSApp.init()` with `{ [weak coach] in await coach?.generateBrief() }`. Safe to + /// leave uncalled: `submitBackgroundRequest()` then fails gracefully and the foreground catch-up + /// (`activateIfEnabled`, called from Coach's `.task`) still generates on next open. + static func register(generateBrief: @escaping () async -> String?) { + BGTaskScheduler.shared.register(forTaskWithIdentifier: bgTaskIdentifier, using: nil) { task in + let completion = TaskCompletionGuard(task: task) + let worker = Task { @MainActor in + task.expirationHandler = { completion.finish(success: false) } + guard isEnabled else { + completion.finish(success: true) + return + } + let succeeded = await catchUpIfDue(generateBrief: generateBrief) + // Single-shot; request the next occurrence regardless of this run's outcome so a failed + // generation still retries at the next wake rather than going silent for good. + submitBackgroundRequest() + completion.finish(success: succeeded) + } + task.expirationHandler = { worker.cancel(); completion.finish(success: false) } + } + } + + /// `BGTask` completion is single-shot even when the normal path races expiration. + private final class TaskCompletionGuard: @unchecked Sendable { + private let task: BGTask + private let lock = NSLock() + private var finished = false + + init(task: BGTask) { self.task = task } + + func finish(success: Bool) { + lock.lock() + defer { lock.unlock() } + guard !finished else { return } + finished = true + task.setTaskCompleted(success: success) + task.expirationHandler = nil + } + } + + /// Submit a background-refresh request for *no earlier than* the next chosen time. iOS decides when + /// (and whether) to actually run it — best-effort, same honesty as `ScheduledDebugExport`. + private static func submitBackgroundRequest() { + let request = BGAppRefreshTaskRequest(identifier: bgTaskIdentifier) + request.earliestBeginDate = Date(timeIntervalSinceNow: secondsToNextOccurrence(timeMinutes)) + try? BGTaskScheduler.shared.submit(request) + } + #endif +} diff --git a/Strand/System/DebugDataDiagnostics.swift b/Strand/System/DebugDataDiagnostics.swift index 4287e75d27..87dd6bcfeb 100644 --- a/Strand/System/DebugDataDiagnostics.swift +++ b/Strand/System/DebugDataDiagnostics.swift @@ -22,6 +22,30 @@ enum DebugDataDiagnostics { /// Strap identity + timezone from persisted defaults (sync, offline-safe). Mirrors the prefs-backed /// portion of the Android strap-state block; keys match the iOS @AppStorage / persisted values. + + /// What the active strap actually delivered over the window — the line that says which scores can + /// exist at all. + /// + /// A 5/MG that never completes its handshake streams live HR and R-R over the standard characteristic + /// and nothing else: motion and steps arrive only with the proprietary offload. Without motion the + /// sleep stager has no HR-only fallback and the workout detector returns before it looks at heart + /// rate, so Rest reads "No data" and no bout is ever found — and until now a report showed those + /// absences with nothing connecting them to their single cause. + /// + /// The window is IN the label. Without it "Provides: motion NO" reads as a capability claim, and a + /// strap simply not worn for two days would be reported as incapable of motion — the opposite kind of + /// wrong from the one this line exists to prevent. Over a window of actual wear, delivered and capable + /// are the same thing; the label keeps that assumption visible instead of implied. + /// The label is padded to 13 like every other in this block ("Model:", "Offload:"), and the window + /// rides the VALUE. "Provides(48h):" is 15 and overhung the column in a report that is aligned by hand + /// and read by eye. + /// Byte-identical to the Kotlin `AndroidDiagnostics.strapProvidesLine`. + static func strapProvidesLine(hr: Bool, rr: Bool, motion: Bool, steps: Bool) -> String { + func mark(_ b: Bool) -> String { b ? "yes" : "NO" } + return "Provides: HR \(mark(hr)) · R-R \(mark(rr)) · motion \(mark(motion)) · steps \(mark(steps))" + + " (last 48h)" + } + static func strapStateLines() -> [String] { var lines: [String] = [] lines.append(String(repeating: "─", count: 40)) @@ -44,22 +68,44 @@ enum DebugDataDiagnostics { // per-device value there; this line is superseded by it and wants the same follow-up as the Apple // write site, which has no peripheral identity to key on today. lines.append("Firmware: \(d.string(forKey: "noop.lastFirmware") ?? "unknown (connect to record)")") + // NOTE: still the legacy GLOBAL key, for the same reason the firmware line above is — strapStateLines + // is sync and prefs-only by contract, and the per-device rule needs a registry read for pairedCount. + // The BLE layer therefore keeps writing the global alongside the per-device one; without that this + // line would not become per-device, it would simply freeze at its pre-upgrade value. Unlike + // firmware there is no per-device block further down to supersede it, so on a multi-strap install + // it can still name the OTHER strap's sync — the Kotlin twin resolves it because its export has the + // registry in hand. Tracked with the same follow-up. let syncSec = d.double(forKey: "lastSyncedAt") lines.append("Last sync: \(syncSec > 0 ? relTime(Date().timeIntervalSince1970 - syncSec) : "never")") // #57: write-health. "Last sync" fires even on an empty/failed offload, so distinguish "rows + // actually landed" from "an offload STALLED on a persist failure" (history won't persist — usually a // backup restored without an app restart, the closed-store class). let now = Date().timeIntervalSince1970 let okAt = d.double(forKey: "sync.lastWriteOkAt") let stalledAt = d.double(forKey: "sync.lastWriteStalledAt") let restoreAt = d.double(forKey: "backup.lastRestoreAt") - lines.append("Data write: \(okAt > 0 ? "rows last landed \(relTime(now - okAt))" : "no rows ever persisted")") + // "Offload:", not "Data write:". The stamp is written ONLY when a backfill session persists + // rows, so it says nothing about live streaming, and the old label read as "this app has stored + // nothing from your strap" on a strap that offloads nothing but streams happily. Twin of the + // Kotlin change. + lines.append("Offload: " + (okAt > 0 + ? "rows last landed \(relTime(now - okAt))" + : "no history rows ever persisted (live HR/R-R are not counted here)")) if stalledAt > 0, stalledAt >= okAt { lines.append(" ⚠ history NOT persisting — last offload STALLED \(relTime(now - stalledAt)) " + "(if you restored a backup, fully restart the app — #57)") } if restoreAt > 0 { lines.append("Last restore: \(relTime(now - restoreAt))") } #if os(iOS) + // What the home-screen widgets cost. Reported unconditionally, including the no-publish case, + // because the absence of widget activity is itself the answer to a drain report. + // + // iOS only: `WidgetTelemetry` lives in StrandiOSShared, which project.yml deliberately keeps + // OUT of the macOS application module. macOS has no home-screen widget to account for. + lines.append(WidgetTelemetry.snapshot().render()) + #endif + #if os(iOS) // #52: iOS Backup & Sync folder-picker health. When users report "won't let me pick a folder", // this pins the failure stage: "cancelled"/"never used" ⇒ the picker's Open button never fired // (an iOS-side picker issue — the in-app "Use NOOP's own folder" fallback sidesteps it); @@ -96,6 +142,24 @@ enum DebugDataDiagnostics { @MainActor static func dynamicLines(repo: Repository) async -> [String] { var lines = strapStateLines() + // #1770 follow-up: which streams the ACTIVE strap actually delivered over the last 48 h. Four + // EXISTS seeks, not counts — see WhoopStore.streamPresence for why that distinction matters on a + // table holding ~190k motion rows a night. + // + // HERE and not in strapStateLines() beside `Offload:`, where it belongs by subject: that + // function is synchronous and holds neither `repo` nor a store handle. The first attempt put it + // there and would not have compiled — in a file the comment below already notes needs macOS to + // build, which is exactly why it went unnoticed locally. Appended first so the output order is + // still the one the reader wants. + if let presenceStore = await repo.storeHandle(), + let present = try? await presenceStore.streamPresence( + deviceId: repo.deviceId, + from: Int(Date().timeIntervalSince1970) - 48 * 3600, + to: Int(Date().timeIntervalSince1970)) { + lines.append(strapProvidesLine(hr: present.hr, rr: present.rr, + motion: present.gravity, steps: present.steps)) + } + // Data state from the preloaded day spine. let days = repo.days lines.append("History: \(days.count) day rows") @@ -199,7 +263,14 @@ enum DebugDataDiagnostics { // back empty, so a healthy install pays nothing. let elsewhere = ((try? await store.rawSampleCountsByDevice(from: cs.startTs, to: cs.endTs)) ?? []) .filter { $0.0 != did } - lines.append(orphanedSamplesLine(activeId: did, othersWithSamples: elsewhere)) + // The registry, so a SECOND strap's night is not reported as a read failure. Only read when + // the active id came back empty, so a healthy install still pays nothing. + let otherStraps = Set( + ((try? DeviceRegistryStore(dbQueue: store.registryWriter).all()) ?? []) + .filter { $0.status != .archived && $0.id != did } + .map(\.id)) + lines.append(orphanedSamplesLine(activeId: did, othersWithSamples: elsewhere, + otherLiveStrapIds: otherStraps)) return lines } if let rem = SleepStager.remFunnelDiagnostic(start: cs.startTs, end: cs.endTs, grav: grav, hr: hr, rr: rr, resp: resp) { @@ -384,7 +455,9 @@ enum DebugDataDiagnostics { } else if behind < -3 * 86400 { lines.append("Strap clock: \(-behind / 86400)d AHEAD of wall (future-dated — alarm unreliable; recent sleep may be misdated, #67)") } else { - lines.append("Strap clock: OK") + // #1706: say what this measures. It reads RECORD timestamps, and sat two lines above an + // alarm readback claiming 2045, which reads as the two contradicting. + lines.append("Strap clock: OK (from record timestamps, not the alarm readback)") } } if let sent = d.object(forKey: "alarm.lastArmSentEpoch") as? Int { @@ -406,14 +479,23 @@ enum DebugDataDiagnostics { } lines.append(line) if let reported = d.object(forKey: "alarm.lastReportedEpoch") as? Int { - let mismatch = abs(reported - sent) > 120 - var rline = "Strap reports: \(alarmStamp(reported))" - + (mismatch ? " ⚠️ MISMATCH — strap didn't accept the time" : " ✓ matches") + // #1706: only judge when both halves are known to be the SAME strap, otherwise this + // blames a device that was never asked. + let verdict = AlarmReadback.verdict( + sentEpoch: sent, + reportedEpoch: reported, + sentDeviceId: d.string(forKey: "alarm.lastArmDeviceId"), + reportedDeviceId: d.string(forKey: "alarm.lastReportedDeviceId")) + var rline = "Strap reports: \(alarmStamp(reported))" + AlarmReadback.suffix(verdict) // #34: consecutive rejections — a persistent refusal (vs a one-off) points at a strap whose // alarm register needs a reset, and is what SmartAlarmView warns the user about at ≥2. let streak = d.integer(forKey: "alarm.rejectStreak") if streak >= 2 { rline += " · \(streak) in a row (register likely needs a reset, #34)" } lines.append(rline) + // The bytes the epoch was decoded from: what tells a stored stale alarm from a misdecode. + if let raw = d.string(forKey: "alarm.lastReportedRaw"), !raw.isEmpty { + lines.append("Readback frame: \(raw)") + } } else { lines.append("Strap reports: (no readback)") } @@ -476,11 +558,35 @@ enum DebugDataDiagnostics { /// /// Pure so the wording is unit-tested without a database, a strap, or a registry. Kotlin twin: /// `com.noop.testcentre.orphanedSamplesLine`. - static func orphanedSamplesLine(activeId: String, othersWithSamples: [(String, Int)]) -> String { + /// `otherLiveStrapIds` is the registered, non-archived device ids OTHER than the active one. It exists + /// because the "not being read" wording was itself an over-assertion — the mirror image of the one it + /// replaced. A wearer with TWO straps has nights owned by the other one, and `DayOwnerResolver` hands + /// each day to whichever device actually holds its data. Samples under another id are then completely + /// normal, and calling that a read failure sends the reader hunting a bug that is not there. Only when + /// the id holding the samples is NOT a live registered strap is the #1193 split the explanation left. + /// + /// That correction then over-corrected. "So this is expected" assumes a night is worn on ONE strap, and + /// a reporter wearing a 4.0 and a 5.0 together hit the case it denies: the active strap banked nothing + /// because its handshake never completed (#1635), while the other strap's rows made the line declare + /// the silence normal. Nothing available here can tell the two apart — the wearer knows which straps + /// were on the wrist and this function cannot — so it states the fork instead of picking a side, and + /// names the sync as what to check in the half where something IS wrong. + static func orphanedSamplesLine(activeId: String, othersWithSamples: [(String, Int)], + otherLiveStrapIds: Set = []) -> String { if othersWithSamples.isEmpty { return "(no raw biometric samples under '\(activeId)' for this night — expected on a freshly " + "re-added strap; reconnect + let a history sync run, then re-export)" } + let ownedByAnotherStrap = othersWithSamples.filter { otherLiveStrapIds.contains($0.0) } + if !ownedByAnotherStrap.isEmpty { + let who = ownedByAnotherStrap.sorted { $0.1 != $1.1 ? $0.1 > $1.1 : $0.0 < $1.0 } + .map { "'\($0.0)' (\($0.1) rows)" } + .joined(separator: ", ") + return "(no raw biometric samples under the ACTIVE id '\(activeId)' for this night — they are " + + "under \(who), another registered strap. If you wore THAT strap this night, this is expected " + + "and the dayOwner line for this date names the owner. If you wore BOTH, the active strap " + + "banked nothing for this night and its sync is what to check, not this line.)" + } // Tie-break on id: Kotlin's sortedByDescending is stable but Swift's `sorted` is NOT, so equal // counts could otherwise order differently on the two platforms and the twin lines would diverge. // The tie-break itself compares Unicode canonical order here and UTF-16 code units in Kotlin; those diff --git a/Strand/System/NotificationPresenter.swift b/Strand/System/NotificationPresenter.swift index 8d534c51ea..9c5448eda0 100644 --- a/Strand/System/NotificationPresenter.swift +++ b/Strand/System/NotificationPresenter.swift @@ -17,6 +17,12 @@ final class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate { private override init() { super.init() } + /// K5: wired by the app root (`StrandApp` on macOS, `StrandiOSApp` on iOS) at launch to route a + /// tapped scheduled morning-brief notification to the Coach screen via `NavRouter.openCoach()`. nil + /// is a safe no-op (the tap is simply not routed) rather than a crash if this ever fires before the + /// root has wired it. + var onCoachBriefTapped: (() -> Void)? + func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, @@ -24,4 +30,18 @@ final class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate { ) { completionHandler([.banner, .sound, .list]) } + + /// Handle a tap on a delivered notification. Only the scheduled morning-brief category (K5) routes + /// anywhere; every other notification (wind-down, smart-alarm, battery/illness) just opens the app + /// to wherever it was, matching the pre-K5 behaviour. + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + if response.notification.request.content.categoryIdentifier == CoachBriefScheduler.notificationCategoryId { + onCoachBriefTapped?() + } + completionHandler() + } } diff --git a/Strand/System/RescoreBackgroundScheduler.swift b/Strand/System/RescoreBackgroundScheduler.swift index 590cbda71d..8933eede0e 100644 --- a/Strand/System/RescoreBackgroundScheduler.swift +++ b/Strand/System/RescoreBackgroundScheduler.swift @@ -36,8 +36,18 @@ enum RescoreBackgroundScheduler { /// Seconds the last COMPLETED pass took. Only ever written by a pass that reached the end. static let lastPassSecondsKey = "noop.rescoreLastPassSeconds" + /// Identifies the MOST RECENT debt, so a pass can tell its own from someone else's (#1681). + /// + /// A token rather than a counter, deliberately. A counter needs read-modify-write, and two triggers + /// marking a debt at the same moment could both read N and both write N+1 — losing an increment, and + /// with it exactly the debt this is meant to protect. A fresh token is a single write: concurrent + /// marks each produce a distinct one, the last wins, and it cannot equal any pass's captured token. + static let owedTokenKey = "noop.rescoreOwedToken" + static var isRescoreOwed: Bool { UserDefaults.standard.bool(forKey: owedKey) } + static var currentOwedToken: String? { UserDefaults.standard.string(forKey: owedTokenKey) } + static var lastCompletedPassSeconds: Double? { guard UserDefaults.standard.object(forKey: lastPassSecondsKey) != nil else { return nil } let value = UserDefaults.standard.double(forKey: lastPassSecondsKey) @@ -47,17 +57,53 @@ enum RescoreBackgroundScheduler { /// Mark a re-score as owed. Called by `IntelligenceEngine` once a pass is past every gate and is /// definitely about to work — so that a kill leaves the debt behind — and by the deferral path, where /// no pass is attempted at all but the work is just as outstanding. - static func markRescoreOwed() { + /// Returns the token stamped on this debt. A pass keeps it and hands it back at completion; every + /// other caller (the deferral path) can ignore it, since it is not the one that will settle up. + @discardableResult + static func markRescoreOwed() -> String { + let token = UUID().uuidString UserDefaults.standard.set(true, forKey: owedKey) + UserDefaults.standard.set(token, forKey: owedTokenKey) + return token + } + + /// May a pass holding [capturedToken] settle the debt? + /// + /// Only if nothing newer was recorded while it ran. The bug in #1681 is that this question was never + /// asked: completion cleared one global boolean unconditionally, so a trigger firing mid-pass — for + /// data that arrived AFTER the pass had already read its inputs — had its debt erased before the + /// correction it was recorded for ever ran. The scoring loop starts at today, so the night most + /// likely to still be syncing is the first thing read and the likeliest to be caught mid-write. + /// + /// A pass with NO token never settles. That errs toward one extra pass, which costs battery; the + /// other direction costs a night's scores until something unrelated happens to re-score it, which is + /// the failure being fixed. + /// + /// Pure, so the rule is pinned without UserDefaults or a background task. + static func maySettleDebt(capturedToken: String?, currentToken: String?) -> Bool { + guard let capturedToken, !capturedToken.isEmpty else { return false } + return capturedToken == currentToken } /// Settle the debt at the end of a completed pass, beside the watermark advance. A pass that is /// killed never reaches this, which is what leaves the mark set for the next launch to find. - static func markRescoreCompleted(seconds: Double) { - UserDefaults.standard.set(false, forKey: owedKey) + /// [owedToken] is the token this pass received from its own `markRescoreOwed()`. The debt is settled + /// only if it is still the current one — see `maySettleDebt`. The pass duration is recorded either + /// way: it is telemetry about THIS pass, and true regardless of whose debt is now outstanding. + /// Returns whether the debt was actually settled, so the caller can SAY when it was not. Declining + /// is the interesting outcome and it must not be silent: #1538 cost three nights because the log + /// recorded that scoring had not happened without ever recording why, and a pass that completes + /// while leaving the mark set looks identical to one that cleared it unless something says so. + @discardableResult + static func markRescoreCompleted(seconds: Double, owedToken: String?) -> Bool { + let settled = maySettleDebt(capturedToken: owedToken, currentToken: currentOwedToken) + if settled { + UserDefaults.standard.set(false, forKey: owedKey) + } if seconds.isFinite, seconds > 0 { UserDefaults.standard.set(seconds, forKey: lastPassSecondsKey) } + return settled } /// Whether the app is somewhere a long pass might not survive. Always false on macOS — see the type doc. diff --git a/Strand/System/TestBundleAssembler.swift b/Strand/System/TestBundleAssembler.swift index 14fb2c8beb..e6b80f5dee 100644 --- a/Strand/System/TestBundleAssembler.swift +++ b/Strand/System/TestBundleAssembler.swift @@ -228,7 +228,7 @@ enum TestBundleAssembler { profileStartedAt: started, questionnaire: TestCentre.answers(profile), build: buildProvenance(), - storage: storage ?? TestBundleMeta.Storage(dbBytes: 0, rows: [:], rawCaptureBytes: 0), + storage: storage ?? TestBundleMeta.Storage(dbBytes: 0, rows: [:], rawCaptureBytes: 0, rowBytes: [:]), redaction: redactionVersion, truncated: truncated, captureCheck: checks) diff --git a/Strand/System/TestBundleMeta.swift b/Strand/System/TestBundleMeta.swift index fa9f792083..cb4884a7cc 100644 --- a/Strand/System/TestBundleMeta.swift +++ b/Strand/System/TestBundleMeta.swift @@ -32,9 +32,14 @@ struct TestBundleMeta: Codable { /// db_bytes plus per-table row counts plus the raw-capture footprint (#590 asked us to surface this). struct Storage: Codable { let dbBytes: Int; let rows: [String: Int]; let rawCaptureBytes: Int + /// #1911: estimated payload bytes per table, keyed as `rows` is. Rows alone cannot say which table + /// holds a large store — `ppgWaveformSample`'s rows carry a BLOB, so it is exactly where a row + /// model misleads. A sampled payload estimate with no index or page overhead, so read it as a + /// lower bound to compare against `db_bytes`, never as a second file size. + let rowBytes: [String: Int] enum CodingKeys: String, CodingKey { case rows - case dbBytes = "db_bytes", rawCaptureBytes = "raw_capture_bytes" + case dbBytes = "db_bytes", rawCaptureBytes = "raw_capture_bytes", rowBytes = "row_bytes" } } diff --git a/Strand/System/TestCentreReport.swift b/Strand/System/TestCentreReport.swift index 7f684203ce..97493255ee 100644 --- a/Strand/System/TestCentreReport.swift +++ b/Strand/System/TestCentreReport.swift @@ -78,20 +78,31 @@ final class TestCentreReport: ObservableObject { } } var rows: [String: Int] = [:] + var rowBytes: [String: Int] = [:] var rawBytes = 0 if let store = await repo?.storeHandle() { - if let c = try? await store.storageStats_rowCountsForTest() { - rows = ["hr": c.hr, "rr": c.rr, "events": c.events, "battery": c.battery, - "spo2": c.spo2, "skinTemp": c.skinTemp, "resp": c.resp, "gravity": c.gravity] - } - if let steps = try? await store.stepCountForTest() { rows["steps"] = steps } - rawBytes = (try? await store.storageStats().rawBytes) ?? 0 + // #1911: every accumulating table, not the nine this used to name. The old shape read eight + // counts out of a test helper and bolted `steps` on beside them, which silently omitted + // ppgHr, sleepState, ppgWaveform and v18Aux — the four Android already reports and flags as + // "can each be large". A footprint that leaves out the only blob table cannot attribute the + // bytes it exists to explain, which is exactly what #1911 needs answered. + if let counts = try? await store.storageRowCounts() { rows = counts } + // #1911: bytes beside the counts. Rows alone cannot say which table holds a large store - + // ppgWaveformSample's rows carry a BLOB, so it is precisely where a row model misleads, and + // precisely the table this question is usually about. Estimated (sampled payload, no index or + // page overhead), so it is a lower bound to compare against db_bytes, not a second file size. + if let bytes = try? await store.storageByteEstimates(rowCounts: rows) { rowBytes = bytes } + // `rawOutboxStats`, not `storageStats`: the latter counts all thirteen decoded tables to + // return a total this probe does not use, and those are full scans on exactly the stores + // this report is pulled from. + rawBytes = (try? await store.rawOutboxStats().bytes) ?? 0 } if let url = live.puffinCaptureURL { rawBytes += (try? fm.attributesOfItem(atPath: url.path))?[.size] as? Int ?? 0 } guard dbBytes > 0 || !rows.isEmpty || rawBytes > 0 else { return nil } - return TestBundleMeta.Storage(dbBytes: dbBytes, rows: rows, rawCaptureBytes: rawBytes) + return TestBundleMeta.Storage(dbBytes: dbBytes, rows: rows, rawCaptureBytes: rawBytes, + rowBytes: rowBytes) } /// The user read the report and confirmed: clear the gate and run the shipped share + deep-link flow. diff --git a/Strand/System/UpdateAvailability.swift b/Strand/System/UpdateAvailability.swift new file mode 100644 index 0000000000..5ab0815221 --- /dev/null +++ b/Strand/System/UpdateAvailability.swift @@ -0,0 +1,183 @@ +import Foundation +import WhoopProtocol + +/// When may NOOP look for a newer release on its own, and when is that worth telling the user about? +/// +/// The manual "Check for updates" button has always been deliberately user-initiated (see `UpdateChecker`). +/// This adds the automatic half, for the reason #1659 asks for it: on iOS there is NO auto-update to fall +/// back on. A sideloaded app cannot install or re-sign an `.ipa` — only AltStore/SideStore can, and only +/// for people who added the source. Everyone else has no way to learn a release happened without going +/// looking, which is exactly the state the issue describes. +/// +/// So the most the app can honestly do is NOTICE and SAY SO. It posts into the Updates inbox the app +/// already has, which means no new surface and no interruption: the bell picks up an unread row, the same +/// way What's New does after an update. +/// +/// Kotlin twin: `com.noop.update.UpdateAvailability`. +enum UpdateAvailability { + + /// ON by default (maintainer's call, #1659). + /// + /// It shipped off first, on the reading that "fully offline, on-device, no telemetry" made an + /// unasked-for launch request wrong on principle. The counter-argument won: a sideloaded app has no + /// store to update it, so a user who never finds this setting is a user who silently runs an old + /// build — which is the whole problem the issue reported. A default nobody discovers is not a + /// compromise, it is the feature not existing. + /// + /// What keeps it honest is what the request IS: one read of a public version number, once a day, + /// after onboarding and the Terms gate. Nothing about the user is sent, nothing is uploaded, and no + /// data leaves the device — the offline promise is about the user's HEALTH DATA, and that is + /// untouched. Anyone who disagrees turns it off in Settings, and it then makes no request at all. + static let defaultEnabled = true + + /// Once a day. The thing being watched moves on the order of days-to-weeks, so anything tighter spends + /// requests (and a little battery) to learn nothing. + static let checkInterval: TimeInterval = 24 * 60 * 60 + + /// May a background check run now? + /// + /// [lastCheckedAt] is epoch seconds, 0 meaning "never checked". A never-checked install is due + /// immediately, so turning the toggle on gives an answer during that session rather than tomorrow. + /// + /// A clock that has moved BACKWARDS (timezone edit, NTP correction, a restored backup) would otherwise + /// park the next check arbitrarily far in the future — `now < lastCheckedAt` is treated as due, which + /// self-heals on the next write. + static func shouldCheckNow(enabled: Bool, + lastCheckedAt: TimeInterval, + now: TimeInterval, + interval: TimeInterval = checkInterval) -> Bool { + guard enabled else { return false } + if lastCheckedAt <= 0 { return true } + if now < lastCheckedAt { return true } // clock went backwards — don't strand the check + return now - lastCheckedAt >= interval + } + + /// Is this result worth a row in the inbox? + /// + /// ONCE PER VERSION. [lastPostedVersion] is persisted, so a user who reads the row and does nothing — + /// which on iOS may be entirely reasonable, since acting means AltStore or a re-sign — is not told + /// again tomorrow, and the day after. An app that nags about something the user cannot act on quickly + /// teaches people to ignore the bell, which costs more than the feature is worth. + static func shouldPost(latest: String, current: String, lastPostedVersion: String?) -> Bool { + guard VersionCheck.isNewer(latest, than: current) else { return false } + return latest != lastPostedVersion + } + + /// Has the install CAUGHT UP with a version we previously announced? + /// + /// The row says "NOOP 10.7.0 is available". Once the user actually installs 10.7.0 that sentence is + /// false, and it sits in the inbox directly beside the What's New row for the same version — an app + /// telling you to get something you already have. Nothing else prunes it, because the row carries no + /// version field of its own; the persisted `lastPostedVersion` is what makes this answerable without + /// parsing the title back out. + /// + /// Must be evaluated even when the toggle is OFF: someone can post a row, switch the check off, then + /// update — and the stale row would otherwise outlive the feature that made it. + static func shouldPruneAnnouncement(lastPostedVersion: String?, current: String) -> Bool { + guard let posted = lastPostedVersion, !posted.isEmpty else { return false } + return !VersionCheck.isNewer(posted, than: current) + } + + /// Assemble the row's body from ALREADY-LOCALIZED fragments. + /// + /// The copy itself deliberately does NOT live here any more. A first cut built these strings as a + /// byte-identical twin, the way the BLE diagnostic lines are — but that is the wrong half of the + /// parity contract. Analytics and stored data are byte-identical; user-facing copy is LOCALIZED per + /// platform, and the sibling What's New row on iOS already is. An unlocalized row would have sat in a + /// translated inbox in English, and no i18n gate would have caught it: they scan SwiftUI `Text` and + /// `@Composable` literals, not model-layer strings. + /// + /// What stays twinned is the ASSEMBLY — order, the single space, the blank line before notes, and the + /// trimming — which is real logic and is what the tests pin. The fragments are separate sentences, so + /// joining them cannot produce the word-order damage that concatenating clauses would. + static func composeMessage(body: String, sideload: String?, notes: String) -> String { + var s = body + if let sideload, !sideload.isEmpty { s += " " + sideload } + let trimmed = notes.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { s += "\n\n" + trimmed } + return s + } +} + +// MARK: - The automatic check + +/// Drives the #1659 automatic check: decide, fetch, post, remember. Kept out of `UpdateAvailability` so +/// that stays pure — every rule this obeys is tested there without a network or a clock. +/// +/// Kotlin twin: `com.noop.update.UpdateWatch`. +enum UpdateWatch { + + enum Keys { + /// On by default; see `UpdateAvailability.defaultEnabled` for why, and what the request is. + static let enabled = "updates.autoCheck" + static let lastCheckedAt = "updates.lastCheckedAt" + static let lastPostedVersion = "updates.lastPostedVersion" + } + + /// The real marketing version straight from the bundle (CFBundleShortVersionString, set from + /// project.yml MARKETING_VERSION), so a check can never compare against a hand-edited constant that + /// has gone stale — which is exactly the bug that once told v7 users they were behind. Hoisted here + /// from SettingsView, which had it private, so the button and the automatic check cannot disagree + /// about what version is installed. + static var installedVersion: String { + (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? AppChangelog.currentVersion + } + + /// Re-entrancy guard. `runIfDue` is called from `.onAppear`, which is not guaranteed to fire once, + /// and the day's slot is stamped INSIDE the task — so two appearances in quick succession could both + /// pass the due check before either had written anything, and fire two requests for one answer. + @MainActor private static var inFlight = false + + static var isEnabled: Bool { + UserDefaults.standard.object(forKey: Keys.enabled) as? Bool ?? UpdateAvailability.defaultEnabled + } + + /// Run a check if one is due, and post to the inbox if the result is worth saying. + /// + /// Every early return is silent BY DESIGN — this runs at launch, and an install with the toggle off + /// (the default) must produce no line, no request and no trace. The manual button remains the loud + /// path: it reports "couldn't check", because there a human is waiting on an answer. + @MainActor + static func runIfDue(currentVersion: String, sideloadHint: Bool, now: Date = Date()) { + let d = UserDefaults.standard + // Runs before every guard below, including the toggle: a stale announcement must not outlive the + // feature that posted it (see `shouldPruneAnnouncement`). + if UpdateAvailability.shouldPruneAnnouncement( + lastPostedVersion: d.string(forKey: Keys.lastPostedVersion), current: currentVersion) { + for item in UpdateStore.shared.items where item.kind == .newVersion { + UpdateStore.shared.remove(item.id) + } + d.removeObject(forKey: Keys.lastPostedVersion) + } + guard !inFlight else { return } + guard UpdateAvailability.shouldCheckNow(enabled: isEnabled, + lastCheckedAt: d.double(forKey: Keys.lastCheckedAt), + now: now.timeIntervalSince1970) else { return } + inFlight = true + Task { + defer { inFlight = false } + // Stamped BEFORE the result is examined, and deliberately: a failed or unparseable read must + // still consume the day's slot. Stamping only on success would retry every launch for as long + // as GitHub is unreachable, which is the one shape a background check must never take. + d.set(now.timeIntervalSince1970, forKey: Keys.lastCheckedAt) + guard let release = await UpdateChecker.fetchLatest() else { return } + guard UpdateAvailability.shouldPost(latest: release.version, + current: currentVersion, + lastPostedVersion: d.string(forKey: Keys.lastPostedVersion)) + else { return } + d.set(release.version, forKey: Keys.lastPostedVersion) + // Localized HERE, at the platform edge, so the row reads in the user's language like the + // What's New row beside it. `composeMessage` only assembles what it is handed. + let body = String(localized: "You're on \(currentVersion). Open Settings and use Check for updates to see what's new and download \(release.version).") + let sideload = sideloadHint + ? String(localized: "AltStore or SideStore can install it for you automatically if you added NOOP's source; a direct .ipa still has to be signed on your device.") + : nil + UpdateStore.shared.post(UpdateItem( + kind: .newVersion, + title: String(localized: "NOOP \(release.version) is available"), + message: UpdateAvailability.composeMessage(body: body, + sideload: sideload, + notes: release.notes))) + } + } +} diff --git a/Strand/System/UpdateChecker.swift b/Strand/System/UpdateChecker.swift index c43de30b16..0449ca9cf8 100644 --- a/Strand/System/UpdateChecker.swift +++ b/Strand/System/UpdateChecker.swift @@ -1,10 +1,20 @@ import Foundation import WhoopProtocol -/// User-initiated "Check for updates": one call to the project's PUBLIC releases API (GitHub), -/// made ONLY when the user taps the button. No background polling and no auto-update — it just reads the latest version -/// number and compares it to the installed one; nothing about the user is sent. (Uses the -/// network-client entitlement, which is otherwise only for the opt-in, off-by-default AI Coach.) +/// "Check for updates": one call to the project's PUBLIC releases API (GitHub), reading the latest +/// version number and comparing it to the installed one. Nothing about the user is sent, and it never +/// installs anything — on iOS no API permits that for a sideloaded app. +/// +/// TWO callers share this, and the distinction matters to anyone auditing what the app does on its own: +/// - the Settings button, which runs only when tapped; +/// - `UpdateWatch`, the #1659 daily check, which runs at most once a day after onboarding and the Terms +/// gate. It is ON by default and switching it off in Settings stops the request entirely. +/// +/// This header previously said the read happened ONLY on a tap. That stopped being true the moment the +/// automatic caller was added, and a false claim here is worse than none: it is the file someone opens to +/// answer "does this app poll?". Documented for real in docs/PRIVACY_SECURITY.md §1.1c. +/// +/// (Uses the network-client entitlement, which is otherwise only for the opt-in, off-by-default AI Coach.) @MainActor final class UpdateChecker: ObservableObject { @@ -20,30 +30,43 @@ final class UpdateChecker: ObservableObject { private static let endpoint = URL(string: "https://api.github.com/repos/ryanbr/noop/releases/latest")! + /// One release read. Shared by the button and the automatic check (#1659) so there is exactly one + /// copy of the endpoint, the headers and the parsing — a second copy is how the two would drift into + /// disagreeing about what "latest" means. + struct Release: Equatable { + let version: String + let url: URL + let notes: String + } + + static func fetchLatest() async -> Release? { + do { + var req = URLRequest(url: Self.endpoint, timeoutInterval: 12) + req.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + let (data, resp) = try await URLSession.shared.data(for: req) + guard (resp as? HTTPURLResponse)?.statusCode == 200, + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let tag = json["tag_name"] as? String, + let urlString = json["html_url"] as? String, + let url = URL(string: urlString) else { return nil } + let latest = tag.hasPrefix("v") ? String(tag.dropFirst()) : tag + return Release(version: latest, url: url, notes: cleanNotes(json["body"] as? String ?? "")) + } catch { + return nil + } + } + func check(currentVersion: String) { guard state != .checking else { return } state = .checking Task { - do { - var req = URLRequest(url: Self.endpoint, timeoutInterval: 12) - req.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") - let (data, resp) = try await URLSession.shared.data(for: req) - guard (resp as? HTTPURLResponse)?.statusCode == 200, - let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let tag = json["tag_name"] as? String, - let urlString = json["html_url"] as? String, - let url = URL(string: urlString) else { - state = .failed - return - } - let latest = tag.hasPrefix("v") ? String(tag.dropFirst()) : tag - let notes = Self.cleanNotes(json["body"] as? String ?? "") - state = VersionCheck.isNewer(latest, than: currentVersion) - ? .available(version: latest, url: url, notes: notes) - : .upToDate(version: latest) - } catch { + guard let release = await Self.fetchLatest() else { state = .failed + return } + state = VersionCheck.isNewer(release.version, than: currentVersion) + ? .available(version: release.version, url: release.url, notes: release.notes) + : .upToDate(version: release.version) } } diff --git a/Strand/System/WindDownNudge.swift b/Strand/System/WindDownNudge.swift index 24eba99849..1002a5e9ad 100644 --- a/Strand/System/WindDownNudge.swift +++ b/Strand/System/WindDownNudge.swift @@ -95,6 +95,27 @@ enum WindDownNudge { return ((raw % day) + day) % day } + /// The day shift for a given weekday's nudge — 0 if the nudge lands on the same day as the wake, + /// -1 if it wraps to the previous evening. The minute-of-day wrap in `nudgeMinuteOfDay` silently + /// loses which day the nudge belongs to; this companion carries that information so the scheduler + /// can pin the trigger to the nudge's day, not the wake's. (#1863) + /// + /// `raw` is always in [-780, 1139] given the clamped inputs (wake ≤ 1439, sleepNeed ≥ 300, lead ≤ 120), + /// so the shift is always 0 or -1 — but the formula is general floor-division for safety. + static func nudgeDayShift(forWeekday weekday: Int) -> Int { + let raw = wakeMinutes(forWeekday: weekday) - sleepNeedMinutes - leadMinutes + let day = 24 * 60 + // Floor division (Swift's `/` truncates toward zero, so adjust for negatives). + return raw < 0 ? -(((-raw - 1) / day) + 1) : raw / day + } + + /// Shift a Calendar weekday (1=Sun…7=Sat) by a day count, wrapping through the week end. (#1863) + /// `shiftedWeekday(weekday: 1, by: -1)` → 7 (Sunday's evening-before nudge fires on Saturday). + static func shiftedWeekday(weekday: Int, by shift: Int) -> Int { + let zeroBased = weekday - 1 + shift + return ((zeroBased % 7) + 7) % 7 + 1 + } + // MARK: - Public API /// The result of enabling the nudge — lets the UI react instead of silently persisting an "on" toggle @@ -190,11 +211,18 @@ enum WindDownNudge { if hasPerDayOverrides { for weekday in 1...7 { let minute = nudgeMinuteOfDay(forWeekday: weekday) + // #1863 — an early wake (e.g. 03:30) wraps the nudge minute to the previous evening + // (19:00). The minute wrap alone loses which DAY that evening belongs to, so the trigger + // was pinned to the wake's own weekday — firing 8+ hours AFTER the wake it precedes. + // Derive the day shift and pin the trigger to the nudge's day, not the wake's. + let shift = nudgeDayShift(forWeekday: weekday) var comps = DateComponents() - comps.weekday = weekday // Calendar weekday 1=Sun…7=Sat → fires weekly on that day + comps.weekday = shiftedWeekday(weekday: weekday, by: shift) // the nudge's day comps.hour = minute / 60 comps.minute = minute % 60 let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true) + // The request id stays keyed to the wake's weekday so toggling/clearing overrides + // always matches the ids `perDayRequestIds` enumerates. center.add(UNNotificationRequest(identifier: "\(requestId)-wd\(weekday)", content: content, trigger: trigger)) } diff --git a/StrandTests/AICoachSleepContextTests.swift b/StrandTests/AICoachSleepContextTests.swift new file mode 100644 index 0000000000..47197c8c8c --- /dev/null +++ b/StrandTests/AICoachSleepContextTests.swift @@ -0,0 +1,52 @@ +import XCTest +@testable import Strand +import WhoopStore + +/// The sleep detail the coach could not see (#1817), and the normalisation that stops it reading +/// nonsense. Twin of the Kotlin `AiCoachContextTest` cases; the formatters are pure, so this runs +/// headlessly with no repo reads. +@MainActor +final class AICoachSleepContextTests: XCTestCase { + + private func engine() -> AICoachEngine { + AICoachEngine(repo: Repository(deviceId: "test-aicoach-sleep")) + } + + private func day(deep: Double? = nil, rem: Double? = nil, + light: Double? = nil, efficiency: Double? = nil) -> DailyMetric { + DailyMetric(day: "2026-06-01", totalSleepMin: 450, efficiency: efficiency, + deepMin: deep, remMin: rem, lightMin: light, disturbances: nil, + restingHr: 52, avgHrv: 65, recovery: 67, strain: 12.3, exerciseCount: nil) + } + + /// `rest 7.5h` used to be every word the coach got about a night. + func testStagesReachTheContextLine() { + let line = engine().dayLine(day(deep: 84, rem: 114, light: 252, efficiency: 0.94)) + XCTAssertTrue(line.contains("deep 1.4h"), line) + XCTAssertTrue(line.contains("REM 1.9h"), line) + XCTAssertTrue(line.contains("light 4.2h"), line) + XCTAssertTrue(line.contains("eff 94%"), line) + } + + /// A night with no staging says so rather than the field vanishing, so the model cannot read a + /// missing stage as a zero. + func testUnstagedNightReportsDashesNotSilence() { + let line = engine().dayLine(day()) + XCTAssertTrue(line.contains("deep —"), line) + XCTAssertTrue(line.contains("REM —"), line) + XCTAssertTrue(line.contains("light —"), line) + XCTAssertTrue(line.contains("eff —"), line) + } + + /// Efficiency arrives as a PERCENTAGE on some import paths, which `SleepView` and `StagesCard` + /// each guard against inline. Without the same guard a bare `* 100` sends "eff 9400%". + func testEfficiencyStoredAsPercentageIsNotMultipliedAgain() { + let e = engine() + XCTAssertEqual(e.efficiencyPercentOrDash(0.94), "94%") + XCTAssertEqual(e.efficiencyPercentOrDash(94.0), "94%") + XCTAssertEqual(e.efficiencyPercentOrDash(nil), "—") + XCTAssertEqual(e.efficiencyPercentOrDash(0), "—") + // Above a fraction but below the percentage split: not a value this can honestly render. + XCTAssertEqual(e.efficiencyPercentOrDash(1.2), "—") + } +} diff --git a/StrandTests/ActiveWorkoutClockTests.swift b/StrandTests/ActiveWorkoutClockTests.swift new file mode 100644 index 0000000000..69f2e51351 --- /dev/null +++ b/StrandTests/ActiveWorkoutClockTests.swift @@ -0,0 +1,69 @@ +import XCTest +@testable import Strand + +/// Pins the pause-aware workout clock every live surface now shares. +/// +/// #1533 added Pause/Resume but only the full-screen live view's timer subtracted the paused time; the +/// Today indicator and the Live card kept reading `now - start`, so pausing froze one clock and left two +/// counting. These cases are the twin of Kotlin `ActiveWorkoutClockTest` — same scenarios, same expected +/// seconds — so the two platforms cannot drift on what "elapsed" means for a paused session. +final class ActiveWorkoutClockTests: XCTestCase { + + private let start = Date(timeIntervalSince1970: 1_000) + + private func elapsed(pausedAt: TimeInterval? = nil, pausedDuration: TimeInterval = 0, + now: TimeInterval) -> Int { + Int(ActiveWorkoutClock.activeElapsed( + start: start, + pausedAt: pausedAt.map { start.addingTimeInterval($0) }, + pausedDuration: pausedDuration, + now: start.addingTimeInterval(now))) + } + + func testActiveElapsedSubtractsCompletedAndOpenPauses() { + XCTAssertEqual(elapsed(now: 65), 65) // never paused + XCTAssertEqual(elapsed(pausedDuration: 20, now: 65), 45) // one finished pause + XCTAssertEqual(elapsed(pausedAt: 30, now: 65), 30) // paused at 30s, still paused + XCTAssertEqual(elapsed(pausedAt: 50, pausedDuration: 10, now: 65), 40) // both + } + + /// The whole point of the fix: while paused the number must not move, however much wall time passes. + /// This is what the two card surfaces got wrong, and a clock that merely subtracts a CONSTANT would + /// still tick — so the open pause has to grow with `now`. + func testClockIsFrozenWhilePaused() { + XCTAssertEqual(elapsed(pausedAt: 30, now: 65), 30) + XCTAssertEqual(elapsed(pausedAt: 30, now: 99), 30) + XCTAssertEqual(elapsed(pausedAt: 30, now: 4_000), 30) + } + + func testNeverCountsBackwards() { + XCTAssertEqual(elapsed(now: -5), 0) // clock skew + XCTAssertEqual(elapsed(pausedDuration: 70, now: 65), 0) // paused longer than the session + } + + /// The Live card carried its own `%d:%02d` formatter with no hour roll-over, so a 90-minute session + /// read "90:00" there and "1:30:00" on the Today indicator beside it. One formatter now. + func testClockFormatRollsOverAtAnHour() { + XCTAssertEqual(ActiveWorkoutClock.clock(0), "0:00") + XCTAssertEqual(ActiveWorkoutClock.clock(65), "1:05") + XCTAssertEqual(ActiveWorkoutClock.clock(3_600), "1:00:00") + XCTAssertEqual(ActiveWorkoutClock.clock(5_400), "1:30:00") + XCTAssertEqual(ActiveWorkoutClock.clock(2 * 3_600 + 5 * 60 + 9), "2:05:09") + XCTAssertEqual(ActiveWorkoutClock.clock(-5), "0:00") + } + + /// The Today indicator renders from a value type, so the pause state has to reach IT — being correct + /// on `AppModel` is not enough, and that is exactly how the card kept counting. + func testIndicatorModelCarriesPauseStateAndFreezes() { + var workout = AppModel.ActiveWorkout(start: start) + workout.pausedAt = start.addingTimeInterval(30) + let model = ActiveWorkoutIndicatorModel.make(from: workout) + XCTAssertEqual(model?.isPaused, true) + XCTAssertEqual(model?.pausedAt, workout.pausedAt) + XCTAssertEqual( + ActiveWorkoutIndicatorModel.elapsed(since: start, pausedAt: model?.pausedAt, + pausedDuration: model?.pausedDuration ?? 0, + now: start.addingTimeInterval(600)), + "0:30") + } +} diff --git a/StrandTests/AlarmReadbackDecodeTests.swift b/StrandTests/AlarmReadbackDecodeTests.swift index 2e4092d94f..e21b8eda4c 100644 --- a/StrandTests/AlarmReadbackDecodeTests.swift +++ b/StrandTests/AlarmReadbackDecodeTests.swift @@ -46,6 +46,40 @@ final class AlarmReadbackDecodeTests: XCTestCase { XCTAssertEqual(FrameRouter.armedAlarmEpoch(in: frame), 1_750_990_944) } + /// The armed GET readback, captured verbatim on fw 41.17.6.0 (2026-08-26, #34/#1706): 11 bytes, + /// `[form 0x01][stored 0x01][u32 LE epoch][00 00][04 00 20]`, epoch at OFFSET 2. The SET-mirror + /// read at offset 1 decodes this very payload to 2_389_889_025 — 2045-09-24, inside the + /// plausibility window — so the gate cannot catch the misread and only this fixture pins the + /// offset. (The arm that produced it sent epoch 1_787_720_400; the strap stored it exactly.) + func testGetReadbackPayload_decodesEpochAtOffsetTwo() { + let frame = responseFrame(payload: [0x01, 0x01, 0xD0, 0x72, 0x8E, 0x6A, 0x00, 0x00, 0x04, 0x00, 0x20]) + XCTAssertEqual(FrameRouter.armedAlarmEpoch(in: frame), 1_787_720_400) + } + + /// All three arm/readback pairs from the same capture set (2026-08-26..28): each readback carries + /// exactly the epoch the arm sent, at offset 2. Three independent values rule out coincidence — + /// under the offset-1 misread each decodes to the value the app actually logged (2389889025 / + /// 2412007425 / 2434125825: the sent epoch's low three bytes shifted up a byte, plus the flag). + func testGetReadback_allThreeCaptures_decodeTheSentEpoch() { + let captures: [([UInt8], UInt32)] = [ + ([0x01, 0x01, 0xD0, 0x72, 0x8E, 0x6A, 0x00, 0x00, 0x04, 0x00, 0x20], 1_787_720_400), + ([0x01, 0x01, 0x50, 0xC4, 0x8F, 0x6A, 0x00, 0x00, 0x04, 0x00, 0x20], 1_787_806_800), + ([0x01, 0x01, 0xD0, 0x15, 0x91, 0x6A, 0x00, 0x00, 0x04, 0x00, 0x20], 1_787_893_200), + ] + for (payload, sentEpoch) in captures { + XCTAssertEqual(FrameRouter.armedAlarmEpoch(in: responseFrame(payload: payload)), sentEpoch) + } + } + + /// An 11-byte readback whose epoch field is implausible falls to the raw-hex line, NOT to the + /// mirror offsets — those are known-wrong for this shape (they fold the stored flag into the + /// epoch's low byte), so trying them here could resurrect the misread this shape exists to kill. + func testGetReadback_implausibleEpoch_fallsToRawHexNotMirrorRead() { + let frame = responseFrame(payload: [0x01, 0x01, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00, 0x20]) + XCTAssertNil(FrameRouter.armedAlarmEpoch(in: frame)) + XCTAssertFalse(FrameRouter.readbackReportsNoAlarm(in: frame)) + } + /// A result-style single byte (e.g. an UNSUPPORTED echo) must not decode; the router falls back to /// the raw-hex line. func testShortGarbagePayload_decodesNil() { @@ -84,6 +118,13 @@ final class AlarmReadbackDecodeTests: XCTestCase { XCTAssertFalse(FrameRouter.readbackReportsNoAlarm(in: frame)) } + /// An armed 11-byte GET readback is NOT "no alarm" — same mutual exclusion, readback shape. + func testArmedGetReadback_isNotReportedAsNoAlarm() { + let frame = responseFrame(payload: [0x01, 0x01, 0xD0, 0x72, 0x8E, 0x6A, 0x00, 0x00, 0x04, 0x00, 0x20]) + XCTAssertEqual(FrameRouter.armedAlarmEpoch(in: frame), 1_787_720_400) + XCTAssertFalse(FrameRouter.readbackReportsNoAlarm(in: frame)) + } + /// A short result-style payload (0x03) is neither an armed epoch NOR the epoch-0 sentinel — it's /// genuinely unparseable, so it still falls through to the raw-hex "unrecognised" branch. func testShortGarbage_isNotReportedAsNoAlarm() { diff --git a/StrandTests/AppTargetFrameCorpusIntegrityTests.swift b/StrandTests/AppTargetFrameCorpusIntegrityTests.swift new file mode 100644 index 0000000000..1472af4b67 --- /dev/null +++ b/StrandTests/AppTargetFrameCorpusIntegrityTests.swift @@ -0,0 +1,74 @@ +import XCTest +@testable import Strand +import WhoopProtocol + +/// Scenario "Strukturelle Mindest- und Genaulänge je Gerätefamilie / Echte aufgezeichnete Rahmen +/// bleiben gültig", for the app target's own frame corpus. +/// +/// The app-target corpus is not a resource directory — `StrandTests/Resources/` holds only a +/// capability table with no frame bytes. The real frames live as hex LITERALS inside three test files: +/// `RawHistoryArchiveReplayTests` and `BackfillerSessionTallyTests` (three real WHOOP 4.0 v25 records, +/// 84 bytes each) and `BatteryResultProvenanceDumpTests` (a real GET_BATTERY_LEVEL COMMAND_RESPONSE). +/// They are repeated here on purpose: those files assert what the frames DECODE to, this one asserts +/// that the tightened envelope rules still admit them at all. If a future change makes the verifier +/// stricter than the hardware, this is the test that says so — the other three would fail with a +/// decode error that reads like a decoder bug. +/// +/// A tightened verifier that rejected any of these would mean real straps losing real records, which +/// is a strictly worse outcome than the forgery the change prevents. +final class AppTargetFrameCorpusIntegrityTests: XCTestCase { + + private func bytes(_ s: String) -> [UInt8] { + var out = [UInt8](); out.reserveCapacity(s.count / 2); var i = s.startIndex + while i < s.endIndex { let j = s.index(i, offsetBy: 2) + out.append(UInt8(s[i.. (hr: Int, rr: Int, events: Int, battery: Int, + spo2: Int, skinTemp: Int, resp: Int, gravity: Int) { + (streams.hr.count, streams.rr.count, 0, 0, + streams.spo2.count, streams.skinTemp.count, streams.resp.count, streams.gravity.count) + } + func enqueueRawBatch(_ meta: RawBatchMeta, frames: [[UInt8]]) async throws {} + func setCursor(_ name: String, _ value: Int) async throws {} + func cursor(_ name: String) async throws -> Int? { nil } + } + + private func le32(_ v: UInt32) -> [UInt8] { + [UInt8(v & 0xFF), UInt8((v >> 8) & 0xFF), UInt8((v >> 16) & 0xFF), UInt8((v >> 24) & 0xFF)] + } + + /// A protocol-correct WHOOP 4.0 HISTORY_END (METADATA, meta_type 2) carrying unix + trim cursor. + private func historyEndFrame(unix: UInt32 = 1_700_000_000, trim: UInt32 = 70_476) -> [UInt8] { + frameFromPayload(le32(unix) + [0, 0] + le32(0) + le32(trim), type: 49, seq: 0, cmd: 2) + } + + /// Collects every trim acknowledgement the Backfiller issues. `@MainActor` because `Backfiller` is. + @MainActor private func makeBackfiller(_ acks: @escaping (UInt32) -> Void) -> Backfiller { + Backfiller(store: NoopStore(), deviceId: "test", ackTrim: { trim, _ in acks(trim) }) + } + + // MARK: - control: the intact frame does acknowledge + + @MainActor func testAnIntactHistoryEndAcknowledgesTheTrim() async { + var acked: [UInt32] = [] + let backfiller = makeBackfiller { acked.append($0) } + backfiller.begin(family: .whoop4) + await backfiller.ingest(historyEndFrame()) + XCTAssertEqual(acked, [70_476], "control: a real HISTORY_END must still advance the offload") + } + + // MARK: - a forged or damaged HISTORY_END acknowledges nothing + + @MainActor func testAHistoryEndWithABrokenHeaderChecksumAcknowledgesNothing() async { + var frame = historyEndFrame() + frame[3] ^= 0xFF // CRC-8 over the length field only + let parsed = parseFrame(frame, family: .whoop4) + XCTAssertEqual(parsed.crcOK, true, "precondition: the payload CRC32 still verifies") + XCTAssertEqual(parsed.parsed["meta_type"], .string("HISTORY_END(2)"), + "precondition: it still decodes as a history end") + XCTAssertEqual(parsed.parsed["trim_cursor"]?.intValue, 70_476, + "precondition: the trim cursor it carries is readable") + + var acked: [UInt32] = [] + let backfiller = makeBackfiller { acked.append($0) } + backfiller.begin(family: .whoop4) + await backfiller.ingest(frame) + XCTAssertTrue(acked.isEmpty, + "acking on this frame frees records the strap would then delete, got \(acked)") + } + + @MainActor func testAHistoryEndWithADeclaredLengthBelowTheMinimumAcknowledgesNothing() async { + var frame = historyEndFrame() + frame[1] = 6; frame[2] = 0 // declared 6 → total 10, under the 11-byte floor + frame[3] = crc8(frame, 1, 3) // with a CORRECT checksum for that word + XCTAssertEqual(parseFrame(frame, family: .whoop4).rejectReason, .belowMinimumLength) + + var acked: [UInt32] = [] + let backfiller = makeBackfiller { acked.append($0) } + backfiller.begin(family: .whoop4) + await backfiller.ingest(frame) + XCTAssertTrue(acked.isEmpty, "got \(acked)") + } + + @MainActor func testAHistoryEndWithATruncatedTrailerAcknowledgesNothing() async { + let frame = Array(historyEndFrame().dropLast(2)) + let parsed = parseFrame(frame, family: .whoop4) + XCTAssertEqual(parsed.rejectReason, .lengthMismatch) + XCTAssertEqual(parsed.parsed["meta_type"], .string("HISTORY_END(2)"), + "precondition: without the gate this frame WOULD read as a history end") + + var acked: [UInt32] = [] + let backfiller = makeBackfiller { acked.append($0) } + backfiller.begin(family: .whoop4) + await backfiller.ingest(frame) + XCTAssertTrue(acked.isEmpty, "got \(acked)") + } + + @MainActor func testAHistoryEndWithTrailingBytesAcknowledgesNothing() async { + let frame = historyEndFrame() + [0x00, 0x00] + XCTAssertEqual(parseFrame(frame, family: .whoop4).rejectReason, .lengthMismatch) + + var acked: [UInt32] = [] + let backfiller = makeBackfiller { acked.append($0) } + backfiller.begin(family: .whoop4) + await backfiller.ingest(frame) + XCTAssertTrue(acked.isEmpty, "got \(acked)") + } + + /// The other half of the forgery: HISTORY_COMPLETE ends the whole offload. A damaged one must not + /// close a session that is still mid-flight. + @MainActor func testADamagedHistoryCompleteDoesNotEndTheOffload() async { + var complete = frameFromPayload([], type: 49, seq: 0, cmd: 3) + complete[3] ^= 0xFF + XCTAssertEqual(parseFrame(complete, family: .whoop4).parsed["meta_type"], + .string("HISTORY_COMPLETE(3)"), "precondition: it still decodes as complete") + + let backfiller = makeBackfiller { _ in } + backfiller.begin(family: .whoop4) + XCTAssertTrue(backfiller.isBackfilling, "precondition: the session is open") + await backfiller.ingest(complete) + XCTAssertTrue(backfiller.isBackfilling, "a damaged HISTORY_COMPLETE must not close the session") + + // Control: the same frame INTACT does close it, so the gate is what made the difference. + let control = makeBackfiller { _ in } + control.begin(family: .whoop4) + await control.ingest(frameFromPayload([], type: 49, seq: 0, cmd: 3)) + XCTAssertFalse(control.isBackfilling) + } + + // MARK: - the acknowledgement block is EXEMPT from the payload bound (D7, decision 4) + + /// The eight bytes the trim acknowledgement mirrors back to the strap reach INTO the CRC32 trailer + /// by design: on the real 25-byte HISTORY_END the trailer starts at 21 and the block runs 17…25. + /// It is an opaque echo, not a decoded field, so the payload bound that now clamps every named + /// field must not touch it. + /// + /// This is the CALLER-side half of that exemption. The protocol package pins the slice itself; what + /// is pinned here is that the app's own reader still hands eight bytes to the ack. Clamped to the + /// trailer it would yield four, and the strap would be acknowledged with a block it never sent — + /// either it refuses the acknowledgement and the offload stops, or it trims on an altered block. + /// Both outcomes are the permanent data loss this whole change exists to prevent, arriving through + /// the fix rather than the bug. + @MainActor func testTheWhoop4AcknowledgementBlockIsEightBytesAndReachesIntoTheTrailer() { + let frame = historyEndFrame() + XCTAssertEqual(frame.count, 25, "precondition: the real HISTORY_END size the exemption is about") + let declared = Int(frame[1]) | (Int(frame[2]) << 8) + XCTAssertEqual(declared, 21, "precondition: the CRC32 trailer starts at 21") + + let endData = Backfiller.endData(from: frame, family: .whoop4) + XCTAssertEqual(endData?.count, 8, "eight bytes, not the four a trailer clamp would leave") + XCTAssertEqual(endData, Array(frame[17..<25]), "…and exactly the bytes at 17…25, unaltered") + XCTAssertNotEqual(endData, Array(frame[17..<21]) + [0, 0, 0, 0], + "a clamped-then-padded block is not the same echo") + } + + /// The 5/MG twin of the same slice (frame[21…29]), so a later tidy-up cannot narrow one family + /// while the other keeps working. + @MainActor func testTheWhoop5AcknowledgementBlockIsAlsoEightBytes() { + let frame = puffinCommandFrame(cmd: 2, seq: 0, + payload: [UInt8](repeating: 7, count: 20), type: 49) + XCTAssertGreaterThanOrEqual(frame.count, 29, "precondition: long enough to hold the block") + let endData = Backfiller.endData(from: frame, family: .whoop5) + XCTAssertEqual(endData?.count, 8) + XCTAssertEqual(endData, Array(frame[21..<29])) + } + + /// The guard the exemption does keep: a frame too short to hold the block yields nil rather than a + /// short read. Not-enough-bytes is a different answer from four-bytes-because-we-clamped. + @MainActor func testAFrameTooShortForTheBlockYieldsNilRatherThanAShortRead() { + XCTAssertNil(Backfiller.endData(from: frameFromPayload([], type: 49, seq: 0, cmd: 2), + family: .whoop4)) + } +} diff --git a/StrandTests/BackfillerSessionTallyTests.swift b/StrandTests/BackfillerSessionTallyTests.swift index 3ad3f0e91d..227157849b 100644 --- a/StrandTests/BackfillerSessionTallyTests.swift +++ b/StrandTests/BackfillerSessionTallyTests.swift @@ -285,4 +285,99 @@ final class BackfillerSessionTallyTests: XCTestCase { "a truly-empty no-cursor session must still warn the strap has no banked history") XCTAssertTrue(joined.contains("fully charge it")) } + + // MARK: - #1683: the stale counterpart to futureRtcLine + + /// A strap that stopped banking weeks ago and one that is caught up produced the SAME "banked no + /// sensor history" line, so neither the user nor a triager could tell them apart. That is why #1541 + /// stayed open and unactionable. + func testACaughtUpOrBrieflyIdleStrapIsNotStale() { + let now = 1_700_000_000 + XCTAssertFalse(Backfiller.isStaleNewestRecord(newestUnix: nil, wallNowUnix: now)) + XCTAssertFalse(Backfiller.isStaleNewestRecord(newestUnix: 0, wallNowUnix: now)) + XCTAssertFalse(Backfiller.isStaleNewestRecord(newestUnix: now, wallNowUnix: now)) + XCTAssertFalse(Backfiller.isStaleNewestRecord(newestUnix: now - 86_400, wallNowUnix: now), + "one night off-wrist is ordinary and must stay silent") + } + + func testTwoDaysIsTheBoundaryAndQualifies() { + let now = 1_700_000_000 + XCTAssertTrue(Backfiller.isStaleNewestRecord(newestUnix: now - 2 * 86_400, wallNowUnix: now)) + } + + /// A future-dated record belongs to `futureRtcLine`; this rule must not also claim it. + func testAFutureDatedRecordIsNotStale() { + let now = 1_700_000_000 + XCTAssertFalse(Backfiller.isStaleNewestRecord(newestUnix: now + 86_400, wallNowUnix: now)) + } + + /// The numbers from the #1683 capture: newest stored record 1785692420 against a wall clock of + /// 1787820941. The user was told only "banked no sensor history"; this says three weeks. + func testStaleRecordLineReportsTheRealCaptureAsTwentyFourDays() { + let line = Backfiller.staleRecordLine(newestUnix: 1_785_692_420, wallNowUnix: 1_787_820_941) + XCTAssertTrue(line.contains("about 24 day(s) old"), line) + XCTAssertTrue(line.contains("stopped saving history"), line) + // The part the old advice omitted: charging alone has already been retried every connect. + XCTAssertTrue(line.contains("re-sends the clock on every connect"), line) + // The test that tells the user whether NOOP is even involved. + XCTAssertTrue(line.contains("official WHOOP app"), line) + XCTAssertFalse(line.contains("\u{2014}")) + } + + /// States the fact, never the diagnosis: a drawered strap shows the same number innocently. + func testStaleRecordLineDoesNotAssertACorruptClock() { + let line = Backfiller.staleRecordLine(newestUnix: 1_700_000_000 - 20 * 86_400, + wallNowUnix: 1_700_000_000) + XCTAssertFalse(line.contains("corrupt"), line) + XCTAssertTrue(line.contains("If you have worn it"), line) + } + + /// The banner is what the user READS; the log line needs a capture export. The standing banner + /// omitted the age entirely and PROMISED that charging "should" work - advice NOOP has effectively + /// retried on every connect for weeks, since it re-sends SET_CLOCK each time. + func testStaleRecordBannerDatesTheSilenceAndPromisesNothing() { + let line = Backfiller.staleRecordBanner(newestUnix: 1_785_692_420, wallNowUnix: 1_787_820_941) + XCTAssertTrue(line.contains("about 24 day(s) old"), line) + XCTAssertTrue(line.contains("If you have been wearing it"), line) + XCTAssertTrue(line.contains("official WHOOP app"), line) + XCTAssertFalse(line.contains("should start banking again"), line) + XCTAssertFalse(line.contains("\u{2014}")) + } + + // ---- #1754: two distinct empty-offload banners ------------------------------------------ + + /// The no-flash-cursor banner (trim=0xFFFFFFFF) names the clock/charge cause — the existing copy, + /// now a named constant so the caller's branch reads as a choice between two states. + func testNoFlashCursorBannerNamesClockAndCharge() { + let line = Backfiller.noFlashCursorBanner + XCTAssertTrue(line.contains("no stored history to hand over"), line) + XCTAssertTrue(line.contains("clock has lost sync"), line) + XCTAssertTrue(line.contains("Fully charge it to 100%"), line) + XCTAssertFalse(line.contains("sensor front-end"), line) + } + + /// The no-sensor-records banner (valid trim, advancing write pointer, zero rows) does NOT name + /// the clock — it points at the sensor front-end or power state, and asks for a strap log rather + /// than promising that charging will fix it. + func testNoSensorRecordsBannerDoesNotBlameTheClock() { + let line = Backfiller.noSensorRecordsBanner + XCTAssertTrue(line.contains("no sensor records"), line) + XCTAssertTrue(line.contains("flash cursor is valid and advancing"), line) + XCTAssertTrue(line.contains("not a clock problem"), line) + XCTAssertTrue(line.contains("sensor front-end or power"), line) + XCTAssertFalse(line.contains("clock has lost sync"), line) + XCTAssertFalse(line.contains("Fully charge it to 100%"), line) + } + + /// The two banners must be distinct strings — a caller choosing between them must not get the + /// same copy for both states. + func testTheTwoBannersAreDistinct() { + XCTAssertNotEqual(Backfiller.noFlashCursorBanner, Backfiller.noSensorRecordsBanner) + } + + /// No em-dash in either banner (project rule). + func testNoEmDashInEitherBanner() { + XCTAssertFalse(Backfiller.noFlashCursorBanner.contains("\u{2014}")) + XCTAssertFalse(Backfiller.noSensorRecordsBanner.contains("\u{2014}")) + } } diff --git a/StrandTests/BackupSyncRoundTripTests.swift b/StrandTests/BackupSyncRoundTripTests.swift index b28e47b336..6e976fc2e9 100644 --- a/StrandTests/BackupSyncRoundTripTests.swift +++ b/StrandTests/BackupSyncRoundTripTests.swift @@ -75,6 +75,7 @@ final class BackupSyncRoundTripTests: XCTestCase { "profile.heightCm": 168.0, "profile.hrMax": 191, "units.system": "imperial", + "units.distance": "metric", ]) // Restore into a throwaway DB path AND a suite-scoped defaults (never the runner's real domain). @@ -93,6 +94,7 @@ final class BackupSyncRoundTripTests: XCTestCase { XCTAssertEqual(defaults.object(forKey: "profile.hrMaxOverride") as? Int, 191, "Canonical profile.hrMax lands on ProfileStore's profile.hrMaxOverride key") XCTAssertEqual(defaults.string(forKey: "units.system"), "imperial") + XCTAssertEqual(defaults.string(forKey: "units.distance"), "metric") } func testLegacySingleEntryZipStillRestoresAndAppliesNoSettings() throws { diff --git a/StrandTests/BackupSyncTests.swift b/StrandTests/BackupSyncTests.swift index c958433e06..ba1c8df69e 100644 --- a/StrandTests/BackupSyncTests.swift +++ b/StrandTests/BackupSyncTests.swift @@ -130,4 +130,45 @@ final class BackupSyncTests: XCTestCase { let out = BackupSync.restorablesNewestFirst([z, a]) { _ in sameMs } XCTAssertEqual(out.map(\.name), [a, z]) } + + // MARK: - Folder identity (#52): "Saving to:" has to say WHICH folder + + func testTrailSlicesAfterTheICloudSyncRoot() { + // macOS shape: merely filtering out the tilde component would leave "Mobile Documents › NOOP". + XCTAssertEqual( + FolderBackup.folderTrail(path: "/Users/x/Library/Mobile Documents/com~apple~CloudDocs/NOOP"), + "NOOP") + // iOS shape, two components deep. + XCTAssertEqual( + FolderBackup.folderTrail( + path: "/private/var/mobile/Library/Mobile Documents/com~apple~CloudDocs/Backups/NOOP"), + "Backups › NOOP") + // A per-app ubiquity container is a sync root too. + XCTAssertEqual( + FolderBackup.folderTrail(path: "/var/mobile/Library/Mobile Documents/iCloud~com~foo~bar/Documents/B"), + "Documents › B") + } + + func testTrailIsEmptyAtTheSyncRootItself() { + // Nothing below the root: `folderLabel` must fall back to naming the place, not emit a dangling "›". + XCTAssertEqual( + FolderBackup.folderTrail(path: "/private/var/mobile/Library/Mobile Documents/com~apple~CloudDocs"), + "") + } + + func testTrailKeepsTwoComponentsOutsideICloud() { + // The #52 internal fallback and an external cloud folder both stay identifiable. + XCTAssertEqual( + FolderBackup.folderTrail(path: "/var/mobile/Containers/Data/Application/9F3A-UUID/Documents/Backups"), + "Documents › Backups") + XCTAssertEqual( + FolderBackup.folderTrail(path: "/private/var/mobile/Containers/Shared/AppGroup/U/Dropbox/NoopBackups"), + "Dropbox › NoopBackups") + } + + func testICloudPathRuleMatchesTheDiagnosticsSignal() { + // One definition: the screen and `restoreListHealth`'s dump must never disagree. + XCTAssertTrue(FolderBackup.isICloudPath("/Users/x/Library/Mobile Documents/com~apple~CloudDocs/NOOP")) + XCTAssertFalse(FolderBackup.isICloudPath("/var/mobile/Containers/Data/Application/U/Documents/Backups")) + } } diff --git a/StrandTests/BondRefusalGiveUpTests.swift b/StrandTests/BondRefusalGiveUpTests.swift index 2f4e16da7e..184d4aca3e 100644 --- a/StrandTests/BondRefusalGiveUpTests.swift +++ b/StrandTests/BondRefusalGiveUpTests.swift @@ -68,4 +68,25 @@ final class BondRefusalGiveUpTests: XCTestCase { XCTAssertTrue(hint.contains("Forget This Device")) XCTAssertFalse(hint.contains("\u{2014}")) } + + /// A per-refusal threshold moves the crossing without duplicating it. Kotlin twin: + /// `BondRefusalGiveUpTest."a lower threshold latches sooner, and the latch still reports once"`. + func testPerRefusalThresholdLatchesSoonerAndStillReportsOnce() { + var g = BondRefusalGiveUp() // pause threshold 5 + XCTAssertFalse(g.recordRefusal(threshold: 3)) + XCTAssertFalse(g.recordRefusal(threshold: 3)) + XCTAssertTrue(g.recordRefusal(threshold: 3), "crossed at 3, not 5") + XCTAssertTrue(g.gaveUp) + XCTAssertFalse(g.recordRefusal(threshold: 3), "the latch reports exactly once") + XCTAssertEqual(4, g.refusals) + } + + /// Omitting it keeps the constructed threshold, so every existing caller is unchanged. + func testOmittingTheThresholdKeepsTheConstructedOne() { + var g = BondRefusalGiveUp() + XCTAssertEqual(5, g.giveUpThreshold) + for _ in 1...4 { XCTAssertFalse(g.recordRefusal()) } + XCTAssertTrue(g.recordRefusal()) + } + } diff --git a/StrandTests/ClientHelloOutcomeTests.swift b/StrandTests/ClientHelloOutcomeTests.swift index fd3459edc4..215b42e2a2 100644 --- a/StrandTests/ClientHelloOutcomeTests.swift +++ b/StrandTests/ClientHelloOutcomeTests.swift @@ -68,5 +68,39 @@ final class ClientHelloOutcomeTests: XCTestCase { XCTAssertFalse(ClientHelloOutcome.isAck( isHelloChar: true, helloOutstanding: true, alreadyBonded: false, isWhoop5: false)) } + + // MARK: - #1883: Apple bond timing tell + + /// A completion faster than one connection interval did not come from the strap — that is the + /// signature of #1635's false bond. The line must name it as UNVERIFIED and reference both issues. + func testSuspiciouslyFastCompletionFlagsUnverifiedBond() { + let line = ClientHelloOutcome.unverifiedBondTimingLine(elapsedMs: 5) + XCTAssertNotNil(line) + XCTAssertTrue(line!.contains("acked after 5ms"), line!) + XCTAssertTrue(line!.contains("under one BLE connection interval"), line!) + XCTAssertTrue(line!.contains("UNVERIFIED"), line!) + XCTAssertTrue(line!.contains("#1883"), line!) + XCTAssertTrue(line!.contains("#1635"), line!) + } + + /// A completion at or above the threshold is plausible and returns nil — no diagnostic line. + func testPlausibleCompletionReturnsNil() { + XCTAssertNil(ClientHelloOutcome.unverifiedBondTimingLine(elapsedMs: 8)) + XCTAssertNil(ClientHelloOutcome.unverifiedBondTimingLine(elapsedMs: 120)) + XCTAssertNil(ClientHelloOutcome.unverifiedBondTimingLine(elapsedMs: 3150)) + } + + /// The threshold matches the Android twin (MIN_PLAUSIBLE_ATT_ROUND_TRIP_MS = 8). + func testThresholdMatchesAndroidTwin() { + XCTAssertEqual(ClientHelloOutcome.minPlausibleAttRoundTripMs, 8) + } + + /// A 0ms completion is the most suspicious — it cannot be a round trip at all. + func testZeroMsCompletionFlagsUnverifiedBond() { + let line = ClientHelloOutcome.unverifiedBondTimingLine(elapsedMs: 0) + XCTAssertNotNil(line) + XCTAssertTrue(line!.contains("acked after 0ms"), line!) + XCTAssertTrue(line!.contains("UNVERIFIED"), line!) + } } diff --git a/StrandTests/CoachConversationDayTests.swift b/StrandTests/CoachConversationDayTests.swift new file mode 100644 index 0000000000..f0fb455e8d --- /dev/null +++ b/StrandTests/CoachConversationDayTests.swift @@ -0,0 +1,120 @@ +import XCTest +@testable import Strand + +/// #1542 twin of `CoachConversationDayTest`. The Coach kept answering today's question inside +/// yesterday's conversation: `messages` outlives a night, so the assistant's own earlier turns state +/// yesterday's figures and the model stays consistent with them. The data was never stale — +/// `buildFullContext()` re-reads on every send — which is why it reads as "the coach only talks about my +/// imported data" rather than as stale numbers. +final class CoachConversationDayTests: XCTestCase { + + /// A fixed UTC calendar, so no case depends on the machine's zone or clock. + private var utc: Calendar = { + var c = Calendar(identifier: .gregorian) + c.timeZone = TimeZone(secondsFromGMT: 0)! + return c + }() + + private func day(_ y: Int, _ m: Int, _ d: Int) -> Int { + var comps = DateComponents() + comps.year = y; comps.month = m; comps.day = d + let date = utc.date(from: comps)! + return AICoachEngine.localEpochDay(date, calendar: utc) + } + + func testFreshSessionIsNeverStale() { + // Nothing sent yet this session: there is no transcript to retire. + XCTAssertFalse(AICoachEngine.isStaleConversation(lastEpochDay: nil, + todayEpochDay: day(2026, 8, 22))) + } + + func testSameDayKeepsTheConversation() { + let today = day(2026, 8, 22) + XCTAssertFalse(AICoachEngine.isStaleConversation(lastEpochDay: today, todayEpochDay: today)) + } + + func testOvernightRetiresTheConversation() { + // The reported case: last turn yesterday evening, next question this morning. + XCTAssertFalse(AICoachEngine.isStaleConversation(lastEpochDay: day(2026, 8, 21), + todayEpochDay: day(2026, 8, 21))) + XCTAssertTrue(AICoachEngine.isStaleConversation(lastEpochDay: day(2026, 8, 21), + todayEpochDay: day(2026, 8, 22))) + } + + func testLongGapRetiresTheConversation() { + XCTAssertTrue(AICoachEngine.isStaleConversation(lastEpochDay: day(2026, 6, 11), + todayEpochDay: day(2026, 8, 22))) + } + + /// Flying west, a timezone change or an NTP correction can move the local day BACKWARDS + /// mid-conversation. That must not wipe a transcript the user is in the middle of, which is why the + /// rule is strictly forward (`>`) and not `!=`. Relaxing it to `!=` fails this case and the year + /// boundary below — the two that give the test teeth. + func testClockMovingBackwardsKeepsTheConversation() { + XCTAssertFalse(AICoachEngine.isStaleConversation(lastEpochDay: day(2026, 8, 22), + todayEpochDay: day(2026, 8, 21))) + } + + func testYearBoundaryIsJustAnotherDay() { + XCTAssertTrue(AICoachEngine.isStaleConversation(lastEpochDay: day(2025, 12, 31), + todayEpochDay: day(2026, 1, 1))) + XCTAssertFalse(AICoachEngine.isStaleConversation(lastEpochDay: day(2026, 1, 1), + todayEpochDay: day(2025, 12, 31))) + } + + /// Swift-only, because Swift-only needs it: Kotlin gets its epoch day from `LocalDate`, while this + /// side computes one. Counted with calendar day arithmetic rather than by dividing an interval by + /// 86,400 — a day is not always 86,400 seconds. Across a DST spring-forward the count must still + /// advance by exactly one, or a transcript would survive a night twice a year. + func testLocalEpochDayAdvancesByOneAcrossADSTBoundary() { + var london = Calendar(identifier: .gregorian) + london.timeZone = TimeZone(identifier: "Europe/London")! + var before = DateComponents() + before.year = 2026; before.month = 3; before.day = 28; before.hour = 12 + var after = DateComponents() + after.year = 2026; after.month = 3; after.day = 29; after.hour = 12 // clocks go forward + let d0 = AICoachEngine.localEpochDay(london.date(from: before)!, calendar: london) + let d1 = AICoachEngine.localEpochDay(london.date(from: after)!, calendar: london) + XCTAssertEqual(d1 - d0, 1, "a 23-hour day must still be one day") + XCTAssertTrue(AICoachEngine.isStaleConversation(lastEpochDay: d0, todayEpochDay: d1)) + } + + /// The symmetric DST case, and the one that actually threatens this implementation. `localEpochDay` + /// counts whole days from the epoch INSTANT to a local midnight, so the count truncates — and on a + /// 25-hour autumn day the extra hour is exactly the kind of slack that could make two local midnights + /// land in the same whole-day bucket. If that happened, a transcript would survive a night once a + /// year and the bug this whole change fixes would come back, seasonally. + /// + /// Spring-forward was tested first because it is the obvious one. It is not the dangerous one. + func testLocalEpochDayAdvancesByOneAcrossTheAutumnFallBack() { + var london = Calendar(identifier: .gregorian) + london.timeZone = TimeZone(identifier: "Europe/London")! + var before = DateComponents() + before.year = 2026; before.month = 10; before.day = 24; before.hour = 12 + var after = DateComponents() + after.year = 2026; after.month = 10; after.day = 25; after.hour = 12 // clocks go back + let d0 = AICoachEngine.localEpochDay(london.date(from: before)!, calendar: london) + let d1 = AICoachEngine.localEpochDay(london.date(from: after)!, calendar: london) + XCTAssertEqual(d1 - d0, 1, "a 25-hour day must still be one day") + XCTAssertTrue(AICoachEngine.isStaleConversation(lastEpochDay: d0, todayEpochDay: d1)) + } + + /// A zone far west of UTC, where the epoch instant itself falls on the PREVIOUS local day. The origin + /// this counts from is not local midnight, so the arithmetic has to stay monotonic anyway — ordering + /// is the only property the rule depends on. + func testLocalEpochDayIsMonotonicWestOfUTC() { + var la = Calendar(identifier: .gregorian) + la.timeZone = TimeZone(identifier: "America/Los_Angeles")! + var comps = DateComponents() + comps.year = 2026; comps.month = 8; comps.day = 21; comps.hour = 23; comps.minute = 30 + let lateEvening = la.date(from: comps)! + let nextMorning = lateEvening.addingTimeInterval(3_600) // 00:30 the next local day + let d0 = AICoachEngine.localEpochDay(lateEvening, calendar: la) + let d1 = AICoachEngine.localEpochDay(nextMorning, calendar: la) + XCTAssertEqual(d1 - d0, 1, "crossing local midnight is one day, whatever the UTC offset") + XCTAssertTrue(AICoachEngine.isStaleConversation(lastEpochDay: d0, todayEpochDay: d1)) + // And the hour before midnight is emphatically the same day. + let earlier = lateEvening.addingTimeInterval(-3_600) + XCTAssertEqual(AICoachEngine.localEpochDay(earlier, calendar: la), d0) + } +} diff --git a/StrandTests/CollectorStandardHRContactTests.swift b/StrandTests/CollectorStandardHRContactTests.swift new file mode 100644 index 0000000000..fe68fd33d3 --- /dev/null +++ b/StrandTests/CollectorStandardHRContactTests.swift @@ -0,0 +1,110 @@ +import XCTest +import WhoopProtocol +import WhoopStore +@testable import Strand + +@MainActor +final class CollectorStandardHRContactTests: XCTestCase { + private final class CaptureStore: StoreWriting { + enum Failure: Error { case requested } + + var inserted: [Streams] = [] + var failNextInsert = false + + func insert(_ streams: Streams, deviceId: String) async throws + -> (hr: Int, rr: Int, events: Int, battery: Int, + spo2: Int, skinTemp: Int, resp: Int, gravity: Int) { + if failNextInsert { + failNextInsert = false + throw Failure.requested + } + inserted.append(streams) + return (streams.hr.count, streams.rr.count, streams.events.count, + streams.battery.count, streams.spo2.count, streams.skinTemp.count, + streams.resp.count, streams.gravity.count) + } + + func enqueueRawBatch(_ meta: RawBatchMeta, frames: [[UInt8]]) async throws {} + } + + /// Repinned for change-only contact recording: only TRANSITIONS record, so thirty identical + /// readings now produce one event rather than thirty. The invariant is unchanged — contact events + /// alone can drive the auto-flush threshold, without a single HR or R-R row — but it has to be + /// measured with readings that actually change, which is what a strap going on and off produces. + func testContactOnlyBatchAutoFlushesAtThreshold() async { + let store = CaptureStore() + let collector = Collector(store: store, deviceId: "whoop-5") + + var contact = StandardHRContact.supportedNotDetected + for ts in 1_750_000_000..<1_750_000_030 { + collector.ingestStandardHR(hr: 0, rr: [], contact: contact, at: ts) + contact = contact == .supportedNotDetected ? .supportedDetected : .supportedNotDetected + } + for _ in 0..<10 where store.inserted.isEmpty { await Task.yield() } + + XCTAssertEqual(store.inserted.count, 1) + guard let inserted = store.inserted.first else { return } + XCTAssertTrue(inserted.hr.isEmpty) + XCTAssertTrue(inserted.rr.isEmpty) + XCTAssertEqual(inserted.events.count, 30) + } + + /// The other half of the same rule, and the one that made the repin necessary: a run of identical + /// readings records ONCE. At the ~1 Hz the standard profile actually arrives at, the old + /// per-reading write was ~86,400 rows a day per device saying the same thing. + func testARunOfIdenticalReadingsRecordsOnlyTheFirst() async { + let store = CaptureStore() + let collector = Collector(store: store, deviceId: "whoop-5") + + for ts in 1_750_000_000..<1_750_000_030 { + collector.ingestStandardHR(hr: 0, rr: [], contact: .supportedNotDetected, at: ts) + } + // One event never reaches the 30-item auto-flush threshold, so nothing has been written yet. + XCTAssertTrue(store.inserted.isEmpty) + + await collector.flushStandardHR() + XCTAssertEqual(store.inserted.count, 1) + XCTAssertEqual(store.inserted.first?.events.count, 1) + XCTAssertEqual(store.inserted.first?.events.first?.ts, 1_750_000_000) + } + + func testWhoopStandardHRCollectorPersistsParsedContact() async { + let store = CaptureStore() + let collector = Collector(store: store, deviceId: "whoop-5") + + collector.ingestStandardHR( + hr: 72, rr: [1_000], contact: .supportedNotDetected, at: 1_750_000_000 + ) + await collector.flushStandardHR() + + XCTAssertEqual(store.inserted, [ + StandardHRMapping.samples( + fromHR: 72, + rr: [1_000], + contact: .supportedNotDetected, + at: 1_750_000_000 + ) + ]) + } + + func testWhoopStandardHRCollectorRebuffersContactAfterInsertFailure() async { + let store = CaptureStore() + store.failNextInsert = true + let collector = Collector(store: store, deviceId: "whoop-5") + + collector.ingestStandardHR( + hr: 73, rr: [], contact: .supportedDetected, at: 1_750_000_001 + ) + await collector.flushStandardHR() + XCTAssertTrue(store.inserted.isEmpty) + + await collector.flushStandardHR() + XCTAssertEqual(store.inserted.first?.events, [ + WhoopEvent( + ts: 1_750_000_001, + kind: StandardHRMapping.contactEventKind, + payload: ["contact": .string("supported_detected")] + ) + ]) + } +} diff --git a/StrandTests/DailySkinTempAbsoluteCarryTests.swift b/StrandTests/DailySkinTempAbsoluteCarryTests.swift index 7c2c3f415c..6ac4faf08d 100644 --- a/StrandTests/DailySkinTempAbsoluteCarryTests.swift +++ b/StrandTests/DailySkinTempAbsoluteCarryTests.swift @@ -14,14 +14,25 @@ import WhoopStore /// automatically, so there is no equivalent seam to drop a column at). final class DailySkinTempAbsoluteCarryTests: XCTestCase { + /// `DailyMetric`'s initializer defaults only from `spo2Pct` onward — its first twelve parameters are + /// required. Every case here needs two or three of them, so they go through this rather than each + /// spelling out ten `nil`s, and the sleep-carry cases below cannot drift into compact calls that do + /// not compile. (They did: these tests shipped uncompilable in #1879, and `StrandTests` runs only in + /// the on-demand app-build, so nothing said so.) private func row(day: String = "2026-08-25", + totalSleepMin: Double? = nil, + deepMin: Double? = nil, + remMin: Double? = nil, + lightMin: Double? = nil, + restingHr: Int? = nil, skinTempC: Double? = nil, skinTempDevC: Double? = nil, - avgHrv: Double? = nil) -> DailyMetric { - DailyMetric(day: day, totalSleepMin: nil, efficiency: nil, deepMin: nil, remMin: nil, - lightMin: nil, disturbances: nil, restingHr: nil, avgHrv: avgHrv, - recovery: nil, strain: nil, exerciseCount: nil, - skinTempDevC: skinTempDevC, skinTempC: skinTempC) + avgHrv: Double? = nil, + sleepHrOnly: Bool? = nil) -> DailyMetric { + DailyMetric(day: day, totalSleepMin: totalSleepMin, efficiency: nil, deepMin: deepMin, + remMin: remMin, lightMin: lightMin, disturbances: nil, restingHr: restingHr, + avgHrv: avgHrv, recovery: nil, strain: nil, exerciseCount: nil, + skinTempDevC: skinTempDevC, skinTempC: skinTempC, sleepHrOnly: sleepHrOnly) } /// An imported winner carries no absolute, so the computed filler's must survive the coalesce. @@ -72,4 +83,55 @@ final class DailySkinTempAbsoluteCarryTests: XCTestCase { XCTAssertEqual(try XCTUnwrap(scored.skinTempDevC), 0.52, accuracy: 0.001) XCTAssertEqual(try XCTUnwrap(scored.recovery), 0.71, accuracy: 0.001) } + + // MARK: - The same four seams, for the HR-only staging flag (#1801) + + /// `sleepHrOnly` rides the identical rebuild path as the absolute above, and the struct has no + /// `copy()` — every seam respells the field list, so a new column is dropped by omission rather than + /// by error. These four are that column's version of the tests above. + + func testScoringKeepsTheStagingFlag() throws { + let scored = row(skinTempC: 34.6).with(recovery: 0.71, skinTempDevC: 0.52, skinTempC: 34.6) + XCTAssertNil(scored.sleepHrOnly, "a row that never carried the flag must not invent one") + let hrOnly = row(day: "2026-09-03", sleepHrOnly: true) + .with(recovery: 0.71, skinTempDevC: nil, skinTempC: nil) + XCTAssertEqual(hrOnly.sleepHrOnly, true, "scoring must not discard how the night was staged") + } + + func testASleepEditKeepsTheStagingFlag() { + let edited = row(day: "2026-09-03", sleepHrOnly: true) + .with(totalSleepMin: 400, efficiency: 0.93, deepMin: 80, remMin: 100, lightMin: 220) + // Correcting the wake window does not change whether the strap banked any motion. + XCTAssertEqual(edited.sleepHrOnly, true) + } + + func testAnImportedRowTakesTheComputedStagingFlag() { + let imported = row(day: "2026-09-03") + let computed = row(day: "2026-09-03", sleepHrOnly: true) + XCTAssertEqual(imported.fillingNilFields(from: computed).sleepHrOnly, true, + "only a scoring pass knows the staging; an import's nil must not erase it") + } + + func testTheFlagMovesWithTheSleepColumnsItDescribes() { + let importRow = row(day: "2026-09-03", totalSleepMin: 300) + let editedComputed = row(day: "2026-09-03", totalSleepMin: 400, sleepHrOnly: true) + let merged = importRow.takingSleepFields(from: editedComputed) + XCTAssertEqual(merged.totalSleepMin, 400) + // The flag describes THOSE stage figures, so it travels with them rather than staying behind. + XCTAssertEqual(merged.sleepHrOnly, true) + } + + /// The Swift twin of the Kotlin coalesce cases: the flag belongs to the sleep GROUP. + func testTheStagingFlagMovesWithTheSleepBlock() { + let winner = row(day: "2026-09-03", restingHr: 55) + let filler = row(day: "2026-09-03", totalSleepMin: 400, deepMin: 39, + remMin: 54, lightMin: 57, sleepHrOnly: true) + XCTAssertEqual(Repository.coalesceDay(winner, filler).sleepHrOnly, true) + } + + func testAWinnerThatOwnsTheSleepBlockKeepsItsOwnStagingFlag() { + let winner = row(day: "2026-09-03", totalSleepMin: 420, deepMin: 90, sleepHrOnly: false) + let filler = row(day: "2026-09-03", totalSleepMin: 300, sleepHrOnly: true) + XCTAssertEqual(Repository.coalesceDay(winner, filler).sleepHrOnly, false) + } } diff --git a/StrandTests/DayCycleRecoveryTests.swift b/StrandTests/DayCycleRecoveryTests.swift new file mode 100644 index 0000000000..55405b1e3e --- /dev/null +++ b/StrandTests/DayCycleRecoveryTests.swift @@ -0,0 +1,88 @@ +import XCTest +import StrandAnalytics +import WhoopStore +@testable import Strand + +@MainActor +final class DayCycleRecoveryTests: XCTestCase { + private enum ReadFailure: Error { case injected } + + func testApplyingCycleStepsPreservesUnrelatedDailyColumns() { + let daily = DailyMetric( + day: "2026-09-04", totalSleepMin: nil, efficiency: nil, deepMin: nil, remMin: nil, + lightMin: nil, disturbances: nil, restingHr: nil, avgHrv: nil, recovery: nil, + strain: nil, exerciseCount: nil, spo2Pct: nil, skinTempDevC: nil, respRateBpm: nil, + steps: 10, activeKcalEst: nil, skinTempC: 34.2, sleepHrOnly: true) + let result = DayCycleIntelligenceIntegration.Result( + stepsByWakeDay: [daily.day: 42], strainByWakeDay: [daily.day: 61], + caloriesByWakeDay: [daily.day: 1_840], workoutCountByWakeDay: [daily.day: 2], + onsetByWakeDay: [:], firstWakeDay: daily.day, + markerUpdate: .preserve) + + let updated = DayCycleIntelligenceIntegration.applying(result, to: daily) + + XCTAssertEqual(updated.steps, 42) + XCTAssertEqual(updated.strain, 61) + XCTAssertEqual(updated.activeKcalEst, 1_840) + XCTAssertEqual(updated.exerciseCount, 2) + XCTAssertEqual(updated.skinTempC, 34.2) + XCTAssertEqual(updated.sleepHrOnly, true) + } + + func testBoundaryRecoveryPropagatesSessionReadFailure() async { + let reader = DayCycleIntelligenceIntegration.BoundaryRecoveryReader( + sleepSessions: { _, _, _ in throw ReadFailure.injected }, + markers: { _, _, _ in XCTFail("marker read must not follow a failed session read"); return [] }) + + do { + _ = try await DayCycleIntelligenceIntegration.recover( + candidates: [(owner: "strap", priority: 0)], reader: reader, + claimedDays: [], windowStart: 1_700_000_000, now: 1_700_086_400, + offsetSec: 0, habitualMidsleepSec: nil) + XCTFail("expected recovery to fail closed") + } catch ReadFailure.injected { + // Expected: callers can distinguish an unread namespace from an authoritative empty one. + } catch { + XCTFail("unexpected error: \(error)") + } + } + + func testBoundaryRecoveryPropagatesMarkerReadFailure() async { + let reader = DayCycleIntelligenceIntegration.BoundaryRecoveryReader( + sleepSessions: { _, _, _ in [] }, + markers: { _, _, _ in throw ReadFailure.injected }) + + do { + _ = try await DayCycleIntelligenceIntegration.recover( + candidates: [(owner: "strap", priority: 0)], reader: reader, + claimedDays: [], windowStart: 1_700_000_000, now: 1_700_086_400, + offsetSec: 0, habitualMidsleepSec: nil) + XCTFail("expected recovery to fail closed") + } catch ReadFailure.injected { + } catch { + XCTFail("unexpected error: \(error)") + } + } + + func testComputePreservesMarkersWhenRecoveryCannotBeRead() async throws { + let store = try await WhoopStore.inMemory() + let reader = DayCycleIntelligenceIntegration.BoundaryRecoveryReader( + sleepSessions: { _, _, _ in throw ReadFailure.injected }, + markers: { _, _, _ in XCTFail("marker read must not follow a failed session read"); return [] }) + + let result = await DayCycleIntelligenceIntegration.compute( + nights: [], editedRows: [], store: store, + candidates: [(owner: "strap", priority: 0)], + physiologyOwners: ["strap"], workouts: [], + windowStart: 1_700_000_000, now: 1_700_086_400, offsetSec: 0, + habitualMidsleepSec: nil, ticksPerStep: 1, mode: .sleepOnset, + cache: DayCycleIntelligenceIntegration.Cache(), profile: UserProfile(), + maxHROverride: nil, effortMethod: .edwards, recoveryReader: reader) + + guard case .preserve = result.markerUpdate else { + return XCTFail("an unread marker namespace must never become an authoritative replacement") + } + XCTAssertTrue(result.stepsByWakeDay.isEmpty) + XCTAssertTrue(result.onsetByWakeDay.isEmpty) + } +} diff --git a/StrandTests/DeviceRawSourceParityTests.swift b/StrandTests/DeviceRawSourceParityTests.swift new file mode 100644 index 0000000000..1230b0761a --- /dev/null +++ b/StrandTests/DeviceRawSourceParityTests.swift @@ -0,0 +1,118 @@ +import Foundation +import XCTest +@testable import Strand +import WhoopStore +import WhoopProtocol + +/// Apple half of the shared Android/Apple raw-source oracle. This tests the repository contract rather +/// than duplicating expected values in Swift, so source precedence and R-R identity cannot drift quietly. +final class DeviceRawSourceParityTests: XCTestCase { + private struct Fixture: Decodable { + struct SourceCase: Decodable { + let name: String + let activeDeviceId: String + let registeredWhoopIds: [String] + let imported: [String] + let computed: [String] + } + struct Beat: Decodable { + let source: String? + let ts: Int + let rrMs: Int + let seq: Int + let ord: Int? + } + struct BeatSource: Decodable { let id: String; let beats: [Beat] } + struct RRCase: Decodable { let name: String; let sources: [BeatSource]; let expected: [Beat] } + let sourceCases: [SourceCase] + let rrCases: [RRCase] + } + + private func fixture() throws -> Fixture { + let testFile = URL(fileURLWithPath: #filePath) + let url = testFile.deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent("Tools/parity_cases/device_raw_sources.json") + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path), + "Shared device-source parity fixture is missing; parity must fail closed") + return try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: url)) + } + + private func production(_ path: String) throws -> String { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + let url = root.appendingPathComponent(path) + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path), + "Production source missing: \(path); consumer wiring guard must fail closed") + return try String(contentsOf: url, encoding: .utf8) + } + + func testSourceResolutionMatchesSharedOracle() throws { + for vector in try fixture().sourceCases { + let actual = Repository.rawWhoopSourceIds(activeDeviceId: vector.activeDeviceId, + registeredWhoopIds: vector.registeredWhoopIds) + XCTAssertEqual(actual, vector.imported, vector.name) + XCTAssertEqual(actual.map { $0 + "-noop" }, vector.computed, vector.name) + } + } + + func testRRIdentityMergeMatchesSharedOracle() throws { + for vector in try fixture().rrCases { + let inputs = vector.sources.map { source in + source.beats.map { RRInterval(ts: $0.ts, rrMs: $0.rrMs, ord: $0.ord, seq: $0.seq) } + } + let actual = Repository.mergeRRByIdentity(inputs) + XCTAssertEqual(actual.map(\.ts), vector.expected.map(\.ts), vector.name) + XCTAssertEqual(actual.map(\.rrMs), vector.expected.map(\.rrMs), vector.name) + XCTAssertEqual(actual.map(\.seq), vector.expected.map(\.seq), vector.name) + XCTAssertEqual(actual.map(\.ord), vector.expected.map(\.ord), vector.name) + } + } + + func testStressCannotPinRawReadsToCanonicalNamespace() throws { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + let source = try String(contentsOf: root.appendingPathComponent("Strand/Screens/StressView.swift"), + encoding: .utf8) + let withoutComments = source.replacingOccurrences(of: #"/\*.*?\*/"#, with: "", + options: .regularExpression) + .split(separator: "\n", omittingEmptySubsequences: false) + .map { $0.split(separator: "//", maxSplits: 1).first.map(String.init) ?? "" } + .joined(separator: "\n") + let directCanonicalRaw = #"(?s)(hrSamples|rrIntervals|gravitySamples)\s*\([^)]*(\"my-whoop\"|Repository\.whoopSource)"# + XCTAssertNil(withoutComments.range(of: directCanonicalRaw, options: .regularExpression), + "Stress raw physiology must resolve via the device-aware repository API") + for call in ["repo.hrSamples(from:", "repo.rrIntervals(from:", "repo.gravitySamplesUnion(from:"] { + XCTAssertTrue(withoutComments.contains(call), "Stress must use device-aware \(call)") + } + } + + func testCoachTimelineAndRhythmUseDeviceAwareRawFacades() throws { + let coach = try production("Strand/AI/AICoach.swift") + XCTAssertTrue(coach.contains("repo.rrIntervals(from:"), + "AI Coach stress context must use Repository's all-source R-R facade") + XCTAssertFalse(coach.contains("store.rrIntervals(\n deviceId: repo.deviceId"), + "AI Coach must not pin R-R to the currently active device") + + let repository = try production("Strand/Data/Repository.swift") + guard let timelineStart = repository.range(of: "func timelineSeries(")?.lowerBound else { + return XCTFail("Repository.timelineSeries is missing") + } + let timeline = String(repository[timelineStart...].prefix(12_000)) + XCTAssertTrue(timeline.contains("rawPhysiologyReadIds(store: store)"), + "Default Full Day timelines must resolve every registered raw source") + XCTAssertFalse(timeline.contains("source.map { [$0] } ?? importedReadIds"), + "Timeline must not fall back to active-plus-canonical only") + XCTAssertTrue(repository.contains("hrBuckets(deviceIds: rawPhysiologyReadIds(store: store)"), + "Coarse HR reads must include every registered WHOOP strap") + XCTAssertTrue(repository.contains("ids: rawPhysiologyReadIds(store: store)"), + "Primary sleep reads must include every registered WHOOP strap") + XCTAssertTrue(repository.contains("ids: rawComputedReadIds(store: store)"), + "Computed sleep reads must include every registered WHOOP strap") + + let pillars = try production("Strand/Screens/V5PillarHosts.swift") + XCTAssertTrue(pillars.contains("repo.rrIntervals(from:"), + "Rhythm must use Repository's all-source R-R facade") + XCTAssertTrue(pillars.contains("repo.gravitySamplesUnion(from:"), + "Rhythm must use Repository's all-source gravity facade") + XCTAssertFalse(pillars.contains("store.rrIntervals(deviceId: repo.deviceId"), + "Rhythm must not pin R-R to the currently active device") + } +} diff --git a/StrandTests/DisUnbondedReadTests.swift b/StrandTests/DisUnbondedReadTests.swift new file mode 100644 index 0000000000..2ccb03c002 --- /dev/null +++ b/StrandTests/DisUnbondedReadTests.swift @@ -0,0 +1,94 @@ +import XCTest +@testable import Strand + +/// The unbonded Device Information Service read, ported from Android (#1647/#1649/#1653). +/// +/// Since the #1635 suppression, "never bonded" is a PERMANENT state on a 5/MG rather than a transient +/// one — so a post-bond-only DIS read means such a strap never learns its firmware, its serial or whether +/// it is an MG at all. These rules decide when to try, and when to stop trying. +final class DisUnbondedReadTests: XCTestCase { + + // MARK: - when to attempt + + func testACleanFiveMGAttemptsTheRead() { + XCTAssertTrue(shouldReadDisUnbonded(isWhoop5: true, bonded: false, + alreadyReadThisLink: false, previouslyRefused: false)) + } + + /// A 4.0 bonds cleanly and its post-bond read already works, so it has no business here. + func testAFourOhNeverAttemptsIt() { + XCTAssertFalse(shouldReadDisUnbonded(isWhoop5: false, bonded: false, + alreadyReadThisLink: false, previouslyRefused: false)) + } + + func testABondedLinkLeavesItToThePostBondPath() { + XCTAssertFalse(shouldReadDisUnbonded(isWhoop5: true, bonded: true, + alreadyReadThisLink: false, previouslyRefused: false)) + } + + func testItIsAtMostOncePerLink() { + XCTAssertFalse(shouldReadDisUnbonded(isWhoop5: true, bonded: false, + alreadyReadThisLink: true, previouslyRefused: false)) + } + + /// The self-limiting half. A read is the same class of operation as the write that provokes the + /// teardown, so retrying one the strap has already refused risks the stable link the suppression + /// bought — and "keeps trying something that will never work" is the failure mode this whole area + /// keeps producing. + func testARefusalIsPermanentForThatStrap() { + XCTAssertFalse(shouldReadDisUnbonded(isWhoop5: true, bonded: false, + alreadyReadThisLink: false, previouslyRefused: true)) + } + + // MARK: - the refusal latch key + + func testTheRefusalKeyIsCaseInsensitiveAndTrimmed() { + XCTAssertEqual(disRefusedPrefKey("AABB-11"), "noop.disRefused.aabb-11") + XCTAssertEqual(disRefusedPrefKey(" AABB-11 "), disRefusedPrefKey("aabb-11")) + } + + func testTheRefusalKeyRejectsNothingToKeyOn() { + XCTAssertNil(disRefusedPrefKey(nil)) + XCTAssertNil(disRefusedPrefKey("")) + XCTAssertNil(disRefusedPrefKey(" ")) + } + + /// The two latches this area now persists must never collide — they answer different questions about + /// the same strap, and one key serving both would make a DIS refusal suppress the handshake. + func testTheDisLatchCannotCollideWithTheHelloLatch() { + XCTAssertNotEqual(disRefusedPrefKey("aabb-11"), helloSuppressionPrefKey("aabb-11")) + } + + // MARK: - the firmware fallback + + /// DIS is the ONLY firmware source an unbonded strap has: the puffin report needs the bond that never + /// happens. + func testDisFirmwarePublishesWhenNothingWasDecoded() { + XCTAssertTrue(shouldPublishDisFirmware(disFirmware: "1.2.3", alreadyDecoded: nil)) + XCTAssertTrue(shouldPublishDisFirmware(disFirmware: "1.2.3", alreadyDecoded: "")) + XCTAssertTrue(shouldPublishDisFirmware(disFirmware: "1.2.3", alreadyDecoded: " ")) + } + + /// FALLBACK, never override. The puffin value is the strap's own account of what it is running and + /// lands later in the connect; a firmware that appeared and then changed would be worse than one that + /// arrived once. + func testDisFirmwareNeverOverridesTheDecodedValue() { + XCTAssertFalse(shouldPublishDisFirmware(disFirmware: "1.2.3", alreadyDecoded: "9.9.9")) + } + + func testThereIsNothingToPublishWithoutADisString() { + XCTAssertFalse(shouldPublishDisFirmware(disFirmware: nil, alreadyDecoded: nil)) + XCTAssertFalse(shouldPublishDisFirmware(disFirmware: " ", alreadyDecoded: nil)) + } + + // MARK: - the failure line (byte-identical to the Kotlin twin) + + /// Generated by compiling the pure file standalone with swiftc and pasted verbatim, per the oracle + /// rule. Without this line a refused read is indistinguishable from one never issued — the ambiguity + /// that made the CLIENT_HELLO failure unreadable for eleven weeks. + func testTheFailureLineIsExact() { + XCTAssertEqual( + disReadFailureLine(uuid: "2A26", status: "err"), + "DIS: read of 2A26 failed err — the strap declined it on this link. If this is an insufficient-authentication or -encryption status, DIS needs an encrypted bond and the firmware cannot be read without one (#490)") + } +} diff --git a/StrandTests/FrameIntegrityGateTests.swift b/StrandTests/FrameIntegrityGateTests.swift new file mode 100644 index 0000000000..c313fe7c2e --- /dev/null +++ b/StrandTests/FrameIntegrityGateTests.swift @@ -0,0 +1,297 @@ +import XCTest +import StrandAnalytics +import WhoopProtocol +import WhoopStore +@testable import Strand + +/// End-to-end, parser → consumer, for the state-driving gates this app owns (frame-integrity, E3): +/// the frame router, the history-metadata classifier, the clock correlation and the data-range reply. +/// +/// Every frame below is a real or protocol-correct frame broken in exactly ONE way — a corrupted +/// header checksum, a declared length under the family minimum, a cut-off CRC32 trailer — so what the +/// consumer refuses is attributable to a single cause. Each test asserts the PRECONDITION that the +/// frame still decodes, because a gate that only ever sees unreadable bytes proves nothing. +/// +/// `@MainActor`: `FrameRouter` and `LiveState` are main-actor types. +@MainActor +final class FrameIntegrityGateTests: XCTestCase { + + private func bytes(_ s: String) -> [UInt8] { + var out = [UInt8](); out.reserveCapacity(s.count / 2); var i = s.startIndex + while i < s.endIndex { let j = s.index(i, offsetBy: 2) + out.append(UInt8(s[i.. [UInt8] { + var f = bytes(realtimeHex); f[3] ^= 0xFF; return f + } + + override func setUp() { + super.setUp() + UserDefaults.standard.removeObject(forKey: "testcentre.active.connection") + UserDefaults.standard.removeObject(forKey: "testcentre.active.master") + } + + override func tearDown() { + UserDefaults.standard.removeObject(forKey: "testcentre.active.connection") + UserDefaults.standard.removeObject(forKey: "testcentre.active.master") + super.tearDown() + } + + // MARK: - the router drives no state from a frame that is not intact + + func testRouterRejectsABrokenHeaderChecksum() { + let frame = headerBroken() + let parsed = parseFrame(frame, family: .whoop4) + XCTAssertEqual(parsed.crcOK, true, "precondition: the payload CRC32 verifies") + XCTAssertEqual(parsed.parsed["heart_rate"]?.intValue, 60, "precondition: the HR still decodes") + + let live = LiveState() + let router = FrameRouter(state: live) + router.handle(frame: frame) + XCTAssertNil(live.heartRate, "a frame that is not intact must not reach live state") + XCTAssertNil(live.lastFrameType, "…and must not be forwarded to any downstream consumer") + } + + func testRouterAcceptsTheSameFrameIntact() { + let live = LiveState() + let router = FrameRouter(state: live) + router.handle(frame: bytes(realtimeHex)) + XCTAssertEqual(live.heartRate, 60, "control: the untouched frame still drives live state") + XCTAssertEqual(live.lastFrameType, "REALTIME_DATA") + } + + func testRouterRejectsADeclaredLengthBelowTheFamilyMinimum() { + var frame = bytes(realtimeHex) + frame[1] = 6; frame[2] = 0 // declared 6 → total 10, under the 11-byte floor + frame[3] = crc8(frame, 1, 3) // with a CORRECT checksum for that length word + XCTAssertEqual(parseFrame(frame, family: .whoop4).rejectReason, .belowMinimumLength) + + let live = LiveState() + FrameRouter(state: live).handle(frame: frame) + XCTAssertNil(live.heartRate) + XCTAssertNil(live.lastFrameType) + } + + func testRouterRejectsATruncatedTrailer() { + let frame = Array(bytes(realtimeHex).dropLast(2)) + let parsed = parseFrame(frame, family: .whoop4) + XCTAssertEqual(parsed.rejectReason, .lengthMismatch) + XCTAssertEqual(parsed.typeName, "REALTIME_DATA", "precondition: it still reads as a frame") + + let live = LiveState() + FrameRouter(state: live).handle(frame: frame) + XCTAssertNil(live.heartRate) + XCTAssertNil(live.lastFrameType) + } + + func testRouterRejectsTrailingBytes() { + let frame = bytes(realtimeHex) + [0x00] + XCTAssertEqual(parseFrame(frame, family: .whoop4).rejectReason, .lengthMismatch) + let live = LiveState() + FrameRouter(state: live).handle(frame: frame) + XCTAssertNil(live.heartRate) + } + + // MARK: - what the router SAYS about a rejection (D3) + + /// Rare-event evidence stays visible with every test mode OFF: the class that used to pass is the + /// one the hardware run's abort criterion reads, and it costs nothing on a link where it never + /// happens. + func testTheClassThatUsedToPassIsAnnouncedWithoutAnyTestMode() { + XCTAssertFalse(TestCentre.active(.connection)) + let live = LiveState() + let router = FrameRouter(state: live) + router.handle(frame: headerBroken()) + XCTAssertEqual(router.rejectTally.payloadCRCOKButEnvelopeRejected, 1) + XCTAssertEqual(router.rejectTally.count(.headerChecksumMismatch), 1) + XCTAssertTrue(live.log.contains { $0.contains("payload CRC32 verified") }, + "the always-on line must name the class: \(live.log)") + // Announced ONCE, at the first sighting — a noisy link must not repeat it per frame. + router.handle(frame: headerBroken()) + XCTAssertEqual(live.log.filter { $0.contains("payload CRC32 verified") }.count, 1) + XCTAssertEqual(router.rejectTally.payloadCRCOKButEnvelopeRejected, 2, "…while the count keeps rising") + } + + /// The ordinary per-connection detail is Test-Centre gated, and silent when the mode is off. + func testPerConnectionRejectDetailIsGatedBehindTheConnectionDomain() { + let live = LiveState() + let router = FrameRouter(state: live) + router.handle(frame: Array(bytes(realtimeHex).dropLast(2))) // lengthMismatch, CRC32 unknown + XCTAssertTrue(live.taggedTail(domain: .connection).isEmpty, + "mode off must emit zero tagged lines: \(live.taggedTail(domain: .connection))") + + TestCentre.activate(.connection) + defer { TestCentre.deactivate(.connection) } + let live2 = LiveState() + let router2 = FrameRouter(state: live2) + router2.handle(frame: Array(bytes(realtimeHex).dropLast(2))) + let tagged = live2.taggedTail(domain: .connection) + XCTAssertEqual(tagged.count, 1, "one line per reason, got \(tagged)") + XCTAssertTrue(tagged[0].contains("frameReject reason=lengthMismatch"), tagged[0]) + XCTAssertTrue(tagged[0].contains("type=REALTIME_DATA"), + "a rejected frame keeps its packet type in the diagnosis: \(tagged[0])") + // One line per REASON, not per frame. + router2.handle(frame: Array(bytes(realtimeHex).dropLast(2))) + XCTAssertEqual(live2.taggedTail(domain: .connection).count, 1) + } + + /// A byte run the reassembler drops never reaches the router at all; folding its monotonic counter + /// in is what keeps its disappearance visible. + func testReassemblerDropsAreFoldedIntoTheConnectionTally() { + let live = LiveState() + let router = FrameRouter(state: live) + let r = Reassembler(family: .whoop4) + let runt: [UInt8] = [0xAA, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] // declares 8 total + let completed = r.feed(runt + bytes(realtimeHex)) + router.noteReassemblerDrops(r.belowMinimumLengthDrops) + XCTAssertEqual(completed.count, 1, "the stream resyncs onto the real frame") + XCTAssertEqual(router.rejectTally.count(.belowMinimumLength), r.belowMinimumLengthDrops) + XCTAssertGreaterThan(r.belowMinimumLengthDrops, 0) + } + + // MARK: - the clock correlation takes no anchor from a frame that is not intact (2.14) + + /// A WHOOP 4.0 GET_CLOCK COMMAND_RESPONSE carrying a device clock value. + /// + /// The command byte must be the REAL `GET_CLOCK` opcode: the decoder only publishes `clock` when + /// the schema resolves `frame[6]` to `GET_CLOCK`, so a made-up opcode yields a frame that verifies + /// and parses but carries no anchor at all — a gate test written on one would pass while proving + /// nothing about the gate. + private func clockResponse() -> [UInt8] { + // [type 36][seq][cmd = GET_CLOCK][resp_seq][result][clock u32 LE] + frameFromPayload([0x0A, 0x01, 0x8F, 0x64, 0xE1, 0x01], + type: 36, seq: 1, cmd: WhoopCommand.getClock.rawValue) + } + + /// The helper is only meaningful if it really carries an anchor — pinned separately so a future + /// opcode change fails HERE, with the cause named, instead of quietly hollowing out the two gate + /// tests below into assertions that nil equals nil. + func testTheClockResponseHelperActuallyCarriesAClock() { + let parsed = parseFrame(clockResponse(), family: .whoop4) + XCTAssertEqual(parsed.typeName, "COMMAND_RESPONSE") + XCTAssertEqual(parsed.parsed["clock"]?.intValue, 31_548_559, + "the helper must decode a clock, or the gate tests prove nothing") + } + + func testClockRefIsTakenFromAnIntactResponse() { + let parsed = parseFrame(clockResponse(), family: .whoop4) + XCTAssertTrue(parsed.ok, "precondition: the response is intact") + XCTAssertNotNil(parsed.parsed["clock"]?.intValue, "precondition: it carries a clock") + XCTAssertNotNil(ClockCorrelation.clockRef(from: parsed, wall: 1_736_365_593), + "control: the useful path still anchors") + } + + func testClockRefIsNotTakenFromABrokenHeaderChecksum() { + var frame = clockResponse() + frame[3] ^= 0xFF + let parsed = parseFrame(frame, family: .whoop4) + XCTAssertEqual(parsed.crcOK, true, "precondition: only the header checksum is broken") + XCTAssertNotNil(parsed.parsed["clock"]?.intValue, "precondition: the clock value still decodes") + XCTAssertNil(ClockCorrelation.clockRef(from: parsed, wall: 1_736_365_593), + "an anchor from a damaged frame mis-stamps every row that follows") + } + + func testClockRefIsNotTakenFromADeclaredLengthBelowTheMinimum() { + var frame = clockResponse() + frame[1] = 6; frame[2] = 0 + frame[3] = crc8(frame, 1, 3) + let parsed = parseFrame(frame, family: .whoop4) + XCTAssertEqual(parsed.rejectReason, .belowMinimumLength) + XCTAssertNil(ClockCorrelation.clockRef(from: parsed, wall: 1_736_365_593)) + } + + // MARK: - the data-range reply (2.15) + + /// A protocol-correct WHOOP 4.0 GET_DATA_RANGE COMMAND_RESPONSE (built here, not captured): its + /// plausible-unix words are the window the offload judges every drained record against (#547). + private func dataRangeResponse() -> [UInt8] { + // The two-byte response header, a filler word, then the oldest and newest markers on the + // 4-byte grid the oldest-scan reads (frame[11] and frame[15]). + let payload: [UInt8] = [0x0A, 0x01, 0x00, 0x00, + 0x00, 0xF1, 0x53, 0x65, // 1_700_000_000 LE + 0x70, 0xE1, 0x4C, 0x68] // 1_749_868_912 LE + return frameFromPayload(payload, type: 36, seq: 1, cmd: WhoopCommand.getDataRange.rawValue) + } + + func testTheDataRangeWindowIsReadFromAnIntactReply() { + let frame = dataRangeResponse() + XCTAssertTrue(parseFrame(frame, family: .whoop4).ok, "precondition: intact") + XCTAssertEqual(BLEManager.dataRangeOldestUnix(from: frame), 1_700_000_000) + XCTAssertEqual(BLEManager.dataRangeNewestUnix(from: frame, wallNowUnix: 1_760_000_000), + 1_749_868_912) + } + + /// The gate the BLE seam applies before it hands this reply to `handleDataRangeResponse`: the + /// bytes still scan to a window, and that is exactly why the verdict has to be consulted. A reply + /// with a broken header checksum narrows the plausibility window, the sync's real records fall + /// through it, the section persists nothing — and is acknowledged anyway. + func testABrokenDataRangeReplyIsRefusedByTheVerdictTheSeamChecks() { + var frame = dataRangeResponse() + frame[3] ^= 0xFF + let parsed = parseFrame(frame, family: .whoop4) + XCTAssertFalse(parsed.ok, "the seam's condition is `parsed.ok`, and it must be false here") + XCTAssertEqual(parsed.rejectReason, .headerChecksumMismatch) + XCTAssertEqual(parsed.crcOK, true, "…even though the payload CRC32 verifies") + // The scan itself is byte-level and would happily answer — which is the point of the gate. + XCTAssertNotNil(BLEManager.dataRangeNewestUnix(from: frame, wallNowUnix: 1_760_000_000)) + } + + func testATruncatedDataRangeReplyIsRefusedByTheSameVerdict() { + let frame = Array(dataRangeResponse().dropLast(2)) + XCTAssertFalse(parseFrame(frame, family: .whoop4).ok) + XCTAssertEqual(parseFrame(frame, family: .whoop4).rejectReason, .lengthMismatch) + } + + // MARK: - the app's own direct call into the verifier (2.5, the fifth caller) + + /// A 5/MG COMMAND_RESPONSE for one of the four ECG opcodes, in the puffin envelope the ECG probe's + /// frame triage reads: type @8, cmd @10, result code @12. + private func ecgProbeReply(result: UInt8 = 0x01) -> [UInt8] { + puffinCommandFrame(cmd: WhoopCommand.toggleLabradorFiltered.rawValue, seq: 1, + payload: [0x42, result, 0x00, 0x00], type: 0x24) + } + + /// `BLEManager.noteEcgProbeFrame` is the ONE place the app calls `verifyFrame` itself rather than + /// going through a parser, and D2 requires each of the five direct callers to have its useful path + /// shown intact under the tightened verifier. Here that path is: a well-formed reply verifies, and + /// the probe's own decoder reads the outcome the report will state. + func testTheEcgProbeGateStillAdmitsAWellFormedReply() { + let frame = ecgProbeReply() + XCTAssertTrue(verifyFrame(frame, family: .whoop5).ok, + "the probe's gate is exactly this call; the useful path must stay open") + XCTAssertEqual(frame[8], 0x24, "precondition: the triage's COMMAND_RESPONSE type byte") + XCTAssertEqual(frame[10], WhoopCommand.toggleLabradorFiltered.rawValue, + "precondition: the triage's opcode byte") + XCTAssertEqual(Whoop5EcgProbe.outcome(frame: frame), .success, + "control: the outcome the probe would report from this reply") + } + + /// And the other half of why that gate is there. The result byte of a damaged reply still decodes, + /// so without the verdict the probe would print the strongest claim it can make — a refusal or a + /// success — about a frame the strap may never have sent that way. Silence is the correct output. + func testTheEcgProbeGateRefusesADamagedReplyWhoseResultByteWouldStillDecode() { + var frame = ecgProbeReply(result: 0x00) // 0 = FAILURE, the loudest verdict + frame[6] ^= 0xFF // break only the CRC16 header checksum + let check = verifyFrame(frame, family: .whoop5) + XCTAssertFalse(check.ok, "the probe must read no byte of this frame") + XCTAssertEqual(check.reason, .headerChecksumMismatch) + XCTAssertEqual(check.crc32OK, true, "…while the payload CRC32 still verifies") + XCTAssertEqual(Whoop5EcgProbe.outcome(frame: frame), .failure, + "the byte-level decode would happily answer — that is what the gate stops") + } + + func testTheEcgProbeGateRefusesTrailingBytesAndTruncation() { + XCTAssertEqual(verifyFrame(ecgProbeReply() + [0x00], family: .whoop5).reason, .lengthMismatch) + XCTAssertEqual(verifyFrame(Array(ecgProbeReply().dropLast(2)), family: .whoop5).reason, + .lengthMismatch) + } +} diff --git a/StrandTests/FusionFormatUnitsTests.swift b/StrandTests/FusionFormatUnitsTests.swift new file mode 100644 index 0000000000..2580db990c --- /dev/null +++ b/StrandTests/FusionFormatUnitsTests.swift @@ -0,0 +1,43 @@ +import XCTest +import StrandAnalytics +@testable import Strand + +/// The fused-record row renders a skin temperature that is STORED in °C. It printed a hardcoded "°C" +/// regardless of the reader's preference, so a Fahrenheit user saw a Celsius number on the fusion screen +/// while every other surface converted it. +final class FusionFormatUnitsTests: XCTestCase { + + /// An ABSOLUTE wrist reading (CSV / Apple Health) converts with the +32 offset. + func testAbsoluteSkinTempFollowsTheReadersUnit() { + XCTAssertEqual(FusionFormat.value(34.1, metricKey: "skin_temp", temperature: .celsius), "34.1 °C") + // 34.1 °C = 93.38 °F + XCTAssertEqual(FusionFormat.value(34.1, metricKey: "skin_temp", temperature: .fahrenheit), "93.4 °F") + } + + /// A DEVIATION (the live BLE pipeline) scales by 9/5 with NO offset and is chipped "Δ". This is the + /// half a naive `temperatureFromCelsius` call gets wrong: it would render +0.7 as "33.3 °F". + func testDeviationSkinTempKeepsItsDeltaChipAndSkipsTheOffset() { + XCTAssertEqual(FusionFormat.value(0.7, metricKey: "skin_temp", temperature: .celsius), "+0.7 Δ°C") + // 0.7 × 9/5 = 1.26 — NOT 33.3 + XCTAssertEqual(FusionFormat.value(0.7, metricKey: "skin_temp", temperature: .fahrenheit), "+1.3 Δ°F") + // The regression #111/#622 names by number: the absolute formula turned this into "24.4 °F". + XCTAssertEqual(FusionFormat.value(-4.2, metricKey: "skin_temp", temperature: .fahrenheit), "-7.6 Δ°F") + } + + /// Fusion is the screen that can show both modes side by side, so the split must come from the value + /// and land either side of VitalBands' 20 °C boundary. + func testTheModeIsDecidedByTheValueNotTheCaller() { + XCTAssertTrue(FusionFormat.value(19.9, metricKey: "skin_temp", temperature: .celsius).contains("Δ")) + XCTAssertFalse(FusionFormat.value(20.0, metricKey: "skin_temp", temperature: .celsius).contains("Δ")) + } + + /// The unit must reach ONLY the temperature branch: every other metric formats identically either + /// way, so a threaded parameter cannot quietly change bpm/ms/steps. + func testNonTemperatureMetricsAreUnaffectedByTheUnit() { + for key in ["resting_hr", "avg_hr", "hrv", "steps", "spo2", "active_kcal", "asleep_min"] { + XCTAssertEqual(FusionFormat.value(62, metricKey: key, temperature: .celsius), + FusionFormat.value(62, metricKey: key, temperature: .fahrenheit), + "\(key) changed with the temperature unit") + } + } +} diff --git a/StrandTests/GpsRouteMathTests.swift b/StrandTests/GpsRouteMathTests.swift index 353a606e33..6c5d4bdeac 100644 --- a/StrandTests/GpsRouteMathTests.swift +++ b/StrandTests/GpsRouteMathTests.swift @@ -240,4 +240,80 @@ final class GpsRouteMathTests: XCTestCase { XCTAssertNil(RouteStore.load(startTs: 1_700_000_500, sport: "Walking", from: defaults)) XCTAssertTrue(RouteStore.loadMap(from: defaults).isEmpty) } + + // MARK: - #1205: imported GPS route lands under the key WorkoutDetailView loads + + /// #1205: a route imported from Apple Health (HKWorkoutRoute) or Health Connect + /// (ExerciseRouteResult.Data) is stored via `RouteStore.store` under the workout's natural + /// key (startTs, sport). `WorkoutDetailView.load()` reads from that same key, so the imported + /// route appears on the detail screen with no UI change. This test pins that contract: the + /// store/load round-trip must survive under the exact key pair an imported workout carries. + func testImportedRouteRoundTripsUnderWorkoutNaturalKey() { + let defaults = freshDefaults() + let startTs = 1_700_000_000 + let sport = "Running" + // Simulate what HealthKitBridge.collectWorkouts does after fetching an HKWorkoutRoute: + // encode the GPS points and store the resulting WorkoutRoute under the workout's key. + let points = [a, b, RouteMath.LatLng(51.4995, -0.1357)] + let route = WorkoutRoute(polyline: RouteMath.encode(points), + distanceM: RouteMath.totalMeters(points)) + RouteStore.store(route, startTs: startTs, sport: sport, into: defaults) + // WorkoutDetailView.load() reads from the same key and decodes the polyline. + let loaded = RouteStore.load(startTs: startTs, sport: sport, from: defaults) + XCTAssertEqual(loaded, route) + let decoded = RouteMath.decode(loaded?.polyline ?? "") + XCTAssertEqual(decoded.count, points.count) + for i in points.indices { + XCTAssertEqual(decoded[i].lat, points[i].lat, accuracy: 1e-5) + XCTAssertEqual(decoded[i].lon, points[i].lon, accuracy: 1e-5) + } + } + + /// The batched write an import uses must be indistinguishable from a run of single stores. + /// + /// `collectWorkouts` collects every imported route and calls `storeAll` once, because `store` + /// rewrites the whole capped map per call and an import calls it per workout. This pins that the + /// shortcut costs nothing: same keys, same values, and the same oldest-first eviction when the batch + /// overflows [RouteStore.maxRoutes]. + func testStoreAllMatchesRepeatedSingleStores() { + let sport = "Running" + func route(_ i: Int) -> WorkoutRoute { + let pts = [RouteMath.LatLng(51.5 + Double(i) / 1000, -0.12), + RouteMath.LatLng(51.5 + Double(i) / 1000, -0.13)] + return WorkoutRoute(polyline: RouteMath.encode(pts), distanceM: RouteMath.totalMeters(pts)) + } + // More than the cap, so eviction is exercised rather than assumed. + let n = RouteStore.maxRoutes + 25 + let entries = (0.. GravitySample { + GravitySample(ts: ts, x: x, y: 0, z: 1) + } + + /// The active strap wins a shared timestamp, and nothing double-counts — the two properties the + /// screen's motion total depends on. + func testActiveWinsTiesAndTheStreamIsTimeOrdered() { + let active = [sample(101, 9.0), sample(103, 9.2)] + let canonical = [sample(100, 5.0), sample(101, 5.1), sample(102, 5.2)] + + let merged = Repository.mergeGravityByTs([active, canonical]) + + XCTAssertEqual(merged.map(\.ts), [100, 101, 102, 103]) + XCTAssertEqual(merged.first { $0.ts == 101 }?.x, 9.0) // active, not canonical + } + + /// A re-added strap with no canonical history must still populate: this is the reported failure on + /// the Android side, and the union has to cover it here too. + func testActiveOnlyMotionSurfacesWhenCanonicalIsEmpty() { + let active = [sample(100, 0.1), sample(101, 0.2)] + XCTAssertEqual(Repository.mergeGravityByTs([active, []]).map(\.ts), [100, 101]) + } + + /// The mirror case, which is the half THIS platform had: canonical history and no active-id rows. + func testCanonicalHistorySurfacesWhenTheActiveIdIsEmpty() { + let canonical = [sample(100, 0.1), sample(101, 0.2)] + XCTAssertEqual(Repository.mergeGravityByTs([[], canonical]).map(\.ts), [100, 101]) + } + + /// A single-device install collapses to one id, and that read is returned UNCHANGED — so this change + /// cannot alter what the overwhelmingly common install already saw. + func testASingleIdReadIsReturnedUntouched() { + let rows = [sample(100, 0.1), sample(101, 0.2)] + XCTAssertEqual(Repository.mergeGravityByTs([rows]), rows) + } + + func testNoMotionUnderEitherIdStaysEmpty() { + XCTAssertTrue(Repository.mergeGravityByTs([[], []]).isEmpty) + } + + /// Unsorted input still comes back time-ordered: the store returns rows per id, and concatenating two + /// id-ordered lists is not itself ordered. + func testOutOfOrderInputIsSorted() { + let a = [sample(300, 1), sample(100, 2)] + let b = [sample(200, 3)] + XCTAssertEqual(Repository.mergeGravityByTs([a, b]).map(\.ts), [100, 200, 300]) + } +} diff --git a/StrandTests/HeaderRedactionTests.swift b/StrandTests/HeaderRedactionTests.swift index f1c4784050..cdb306207f 100644 --- a/StrandTests/HeaderRedactionTests.swift +++ b/StrandTests/HeaderRedactionTests.swift @@ -29,6 +29,27 @@ final class HeaderRedactionTests: XCTestCase { XCTAssertFalse(containsRawMac(LiveState.redactPii(line))) } + /// #1303: once a strap ADOPTS its serial the device id IS the serial, and this header enumerates every + /// paired device. Neither older rule covers that shape — the MAC rule wants MAC form, the serial rule + /// wants the literal "WHOOP " then a digit — so before this the header leaked it verbatim. + func testAnAdoptedSerialIdIsMasked() { + let line = " device id=whoop-MGB1234567 status=ACTIVE brand=WHOOP model=5.0 MG lastSeen=1h ago" + let safe = LiveState.redactPii(line) + XCTAssertFalse(safe.contains("1234567"), "serial survived: \(safe)") + XCTAssertTrue(safe.contains("whoop-MGB…"), "three-character prefix should remain: \(safe)") + } + + /// The `-noop` suffix is not identifying and is what separates derived rows from measured ones in the + /// per-source counts, so it must survive the mask that hides the serial in front of it. + func testTheComputedSiblingMarkerSurvives() { + XCTAssertEqual(LiveState.redactPii("Days: whoop-MGB1234567-noop=25"), "Days: whoop-MGB…-noop=25") + } + + /// Anything too short to be accepted as a serial upstream must not be masked as one here either. + func testAnIdTooShortToBeASerialIsUntouched() { + XCTAssertEqual(LiveState.redactPii("id=whoop-ABCDE"), "id=whoop-ABCDE") + } + func testAnIdWithNoAddressIsUntouched() { let line = " device id=my-whoop status=ACTIVE brand=WHOOP model=WHOOP 4.0 lastSeen=3h 10m ago" XCTAssertEqual(LiveState.redactPii(line), line) diff --git a/StrandTests/HelloSuppressionTests.swift b/StrandTests/HelloSuppressionTests.swift new file mode 100644 index 0000000000..faefe702c3 --- /dev/null +++ b/StrandTests/HelloSuppressionTests.swift @@ -0,0 +1,212 @@ +import XCTest +import WhoopProtocol +@testable import Strand + +/// The #1635 CLIENT_HELLO suppression, ported from Android. +/// +/// The rules are pure so they can be pinned without a radio — which matters more here than usual, because +/// nothing else validates this path: BLE behaviour is not CI-testable, and the app target has no default +/// CI at all. +final class HelloSuppressionTests: XCTestCase { + + // MARK: - shouldSendClientHello + + func testTheHelloIsSentUntilTheLatchSets() { + XCTAssertTrue(shouldSendClientHello(suppressedForDevice: false, userInitiated: false)) + XCTAssertFalse(shouldSendClientHello(suppressedForDevice: true, userInitiated: false)) + } + + /// Suppression is a fallback for AUTOMATIC reconnects, never a permanent verdict. Someone who puts the + /// strap in pairing mode and presses Connect must get a fresh try — that is also the only way to clear + /// the latch short of a genuine bond. + func testAnExplicitConnectAlwaysReattempts() { + XCTAssertTrue(shouldSendClientHello(suppressedForDevice: true, userInitiated: true)) + XCTAssertTrue(shouldSendClientHello(suppressedForDevice: false, userInitiated: true)) + } + + // MARK: - giveUpSuppressesHello + + /// The two give-up causes want OPPOSITE treatment, and collapsing them is the bug this split exists to + /// undo: before it, a strap that could still stream live HR was treated exactly like one that could not. + func testOnlyAnUnansweredHandshakeSuppresses() { + XCTAssertTrue(giveUpSuppressesHello(authRefusal: false)) // vanished write -> keep the link + XCTAssertFalse(giveUpSuppressesHello(authRefusal: true)) // strap said no -> pause instead + } + + // MARK: - countsAsBondRefusal + + /// The family gate is what keeps a WHOOP 4.0 out of this entirely. The latch is stored per device with + /// no family in the key, so without this gate a 4.0 could latch and silently lose history sync. + func testAFourOhCanNeverReachTheSuppression() { + for family in DeviceFamily.allCases where family != .whoop5 { + XCTAssertFalse( + countsAsBondRefusal(isAuthRefusalStatus: true, helloUnacked: true, + alreadyBonded: false, family: family), + "\(family) must not count toward the 5/MG handshake give-up") + } + } + + func testBothCausesCountForAFiveMG() { + // The strap actively declined. + XCTAssertTrue(countsAsBondRefusal(isAuthRefusalStatus: true, helloUnacked: false, + alreadyBonded: false, family: .whoop5)) + // The write simply vanished — invisible to this platform's give-up before the port. + XCTAssertTrue(countsAsBondRefusal(isAuthRefusalStatus: false, helloUnacked: true, + alreadyBonded: false, family: .whoop5)) + // Neither: an ordinary drop is not a pairing problem. + XCTAssertFalse(countsAsBondRefusal(isAuthRefusalStatus: false, helloUnacked: false, + alreadyBonded: false, family: .whoop5)) + } + + /// A bonded link that drops is not a pairing failure, and counting it would latch the suppression on a + /// strap whose handshake demonstrably works. + func testAlreadyBondedNeverCounts() { + XCTAssertFalse(countsAsBondRefusal(isAuthRefusalStatus: true, helloUnacked: true, + alreadyBonded: true, family: .whoop5)) + } + + // MARK: - the latch key + + /// Lowercased for the same reason the firmware key is: the same strap can present its identifier in + /// different cases across sessions, and a case-sensitive key would latch a second time under a second + /// key instead of reading the first. + func testTheLatchKeyIsCaseInsensitiveAndTrimmed() { + let upper = helloSuppressionPrefKey("AABBCCDD-1122") + XCTAssertEqual(upper, "noop.helloUnanswered.aabbccdd-1122") + XCTAssertEqual(helloSuppressionPrefKey(" aabbccdd-1122 "), upper) + } + + func testTheLatchKeyRejectsNothingToKeyOn() { + XCTAssertNil(helloSuppressionPrefKey(nil)) + XCTAssertNil(helloSuppressionPrefKey("")) + XCTAssertNil(helloSuppressionPrefKey(" ")) + } + + // MARK: - the latch itself + + func testTheLatchRoundTripsAndClearsCompletely() { + let id = "unit-test-\(#function)" + defer { HelloSuppressionStore.setSuppressed(id, false) } + + XCTAssertFalse(HelloSuppressionStore.suppressed(id)) + HelloSuppressionStore.setSuppressed(id, true) + XCTAssertTrue(HelloSuppressionStore.suppressed(id)) + // Case must not open a second latch beside the first. + XCTAssertTrue(HelloSuppressionStore.suppressed(id.uppercased())) + HelloSuppressionStore.setSuppressed(id, false) + XCTAssertFalse(HelloSuppressionStore.suppressed(id)) + // Cleared means REMOVED, so a strap that never latched leaves nothing behind. + XCTAssertNil(UserDefaults.standard.object(forKey: helloSuppressionPrefKey(id)!)) + } + + /// An unkeyable id must not throw or write a stray default. + func testStoringAgainstNothingIsANoOp() { + HelloSuppressionStore.setSuppressed(nil, true) + XCTAssertFalse(HelloSuppressionStore.suppressed(nil)) + } + + // MARK: - the hints (byte-identical to the Kotlin twin) + + /// The suppression branch pauses NOTHING, so its hint must not say auto-reconnect stopped — that was + /// the confidently-wrong diagnostic #1635 produced twice. It names what was lost and the one action + /// that restores the attempt. + func testTheSuppressionHintDoesNotClaimAnythingIsPaused() { + let hint = BondRefusalGiveUp.helloSuppressedHint() + XCTAssertTrue(hint.contains("live heart rate keeps streaming")) + XCTAssertTrue(hint.contains("History sync stays unavailable")) + // #1635 follow-up: the hint no longer LEADS with a retry. Framing a strap that refuses pairing as + // a retryable failure invited the hammering the give-up latch exists to stop, and a field report + // read the whole hint and still asked how to fix it. Pairing mode comes first now; Connect follows. + XCTAssertTrue(hint.contains("pairing mode")) + XCTAssertTrue(hint.contains("tap Connect")) + // And it must NOT claim these are unavailable: since #1884 an HR-only night reports both. + XCTAssertFalse(hint.contains("HRV and resting heart rate are unavailable")) + // The hint must name what an unbonded strap actually costs, not just history sync: motion, + // skin temperature, SpO2 and respiratory stop, so sleep falls to the HR-only stager. A field log + // showed a week of blank Recovery Vitals under the old wording, which pointed only at a feature + // the user was not missing. (It used to end "and HRV + resting HR are nulled" — #1884 made that + // false, and leaving it here would have contradicted the assertion four lines down.) + XCTAssertTrue(hint.contains("motion")) + // #1884 repinned the tail of this: naming HRV and resting HR as LOSSES stopped being true when an + // HR-only night began reporting both. What survives from #1878 is the reason they were named at + // all - the strap stops sending motion, so sleep falls to the HR-only stager. That still holds. + XCTAssertTrue(hint.contains("staged from heart rate alone")) + XCTAssertFalse(hint.lowercased().contains("paused")) + XCTAssertFalse(hint.lowercased().contains("stopped retrying")) + } + + /// The unanswered-handshake hint must not name a cause. `pausedHint` blames the official WHOOP app or a + /// stale pairing, which an INSUFFICIENT_AUTHENTICATION refusal supports and a vanished write does not. + func testTheUnansweredHintNamesNoCause() { + let unanswered = BondRefusalGiveUp.pausedHintHandshakeUnanswered() + XCTAssertTrue(unanswered.contains("never completes")) + XCTAssertFalse(unanswered.contains("WHOOP app")) + XCTAssertFalse(unanswered.contains("Forget This Device")) + // …whereas the auth-refusal hint is exactly where naming that cause belongs. + XCTAssertTrue(BondRefusalGiveUp.pausedHint().contains("WHOOP app")) + } + + /// Likewise the epitaph: `epitaphLine` asserts "almost certainly held by the official WHOOP app", which + /// only an auth refusal supports. + func testTheSuppressionEpitaphAssertsNoCause() { + let e = BondRefusalGiveUp.helloSuppressedEpitaph(refusals: 5, opaqueId: "abcd1234") + XCTAssertTrue(e.contains("[abcd1234]")) + XCTAssertTrue(e.contains("5x in a row")) + XCTAssertFalse(e.contains("almost certainly")) + XCTAssertFalse(e.contains("\u{2014}")) // project rule: no em-dash in these lines + } + + // MARK: - the keep-alive gate + + /// The defect this gate exists for: the timer used to require the genuine encrypted bond, which a + /// suppressed 5/MG never reaches — so the liveness watchdog was switched off for exactly the link the + /// suppression keeps up for hours, and a stalled stream would have had nothing to bounce it. + func testAStreamingSuppressedFiveMGReachesTheWatchdog() { + XCTAssertTrue(keepAliveMayRun(connected: true, didBond: false, bonded: true, family: .whoop5)) + } + + /// Before HR arrives there is nothing to watch, so starting the timer early at the suppression point + /// is harmless — the gate simply returns false until the stream marks the link established (#8). + func testItStaysShutUntilTheStreamActuallyStarts() { + XCTAssertFalse(keepAliveMayRun(connected: true, didBond: false, bonded: false, family: .whoop5)) + } + + /// Narrower than the Android twin on purpose: only a 5/MG is admitted unbonded. A 4.0 reaching here + /// without a bond would be a real fault, not a suppressed link, and must not be kept alive. + func testOnlyAFiveMGIsAdmittedWithoutABond() { + for family in DeviceFamily.allCases where family != .whoop5 { + XCTAssertFalse(keepAliveMayRun(connected: true, didBond: false, bonded: true, family: family), + "\(family) must not run the keep-alive unbonded") + } + } + + func testABondedLinkAlwaysRunsAndADroppedOneNever() { + for family in DeviceFamily.allCases { + XCTAssertTrue(keepAliveMayRun(connected: true, didBond: true, bonded: true, family: family)) + XCTAssertFalse(keepAliveMayRun(connected: false, didBond: true, bonded: true, family: family)) + } + } + + // MARK: - giveUpThresholdFor + + /// The Kotlin twin is `HelloSuppressionTest."an unanswered handshake gives up sooner than an auth + /// refusal"`. Both numbers matter: 5 keeps the auth branch's patience, 3 is the unanswered branch. + func testUnansweredHandshakeGivesUpSoonerThanAuthRefusal() { + XCTAssertEqual(5, giveUpThresholdFor(authRefusal: true, pauseThreshold: 5)) + XCTAssertEqual(3, giveUpThresholdFor(authRefusal: false, pauseThreshold: 5)) + // Above the hint threshold, so a PERSISTED verdict still keeps a cycle of margin. + XCTAssertGreaterThan(unansweredGiveUpThreshold, 2) + } + + /// Patience and treatment read the SAME refusal. Keyed apart they could disagree — pausing on a branch + /// that waited the suppression count, or vice versa. Kotlin twin: "the threshold and the treatment + /// read the same refusal". + func testThresholdAndTreatmentReadTheSameRefusal() { + for auth in [true, false] { + let suppresses = giveUpSuppressesHello(authRefusal: auth) + let threshold = giveUpThresholdFor(authRefusal: auth, pauseThreshold: 5) + XCTAssertEqual(suppresses, threshold == unansweredGiveUpThreshold) + } + } + +} diff --git a/StrandTests/HexPayloadRedactionTests.swift b/StrandTests/HexPayloadRedactionTests.swift new file mode 100644 index 0000000000..788ca799c2 --- /dev/null +++ b/StrandTests/HexPayloadRedactionTests.swift @@ -0,0 +1,84 @@ +import XCTest +@testable import Strand + +/// #1833: a WHOOP serial that arrives as HEX rather than as text. +/// +/// Every rule in `LiveState.redactPii` matches an identifier written as characters, so none of them can +/// see one encoded as hex. On a 5/MG, event 109 carries the strap serial as plain ASCII inside its +/// payload, and this side dumps hex in several places — `frame=` (the clock diagnostic), `[raw …]` and +/// `(raw …)` (the alarm readback), and the #900 whole-frame dump. The serial went into the log we ask +/// people to attach to public issues while the MAC rule kept firing, so the line still looked redacted. +/// +/// Twin of the Kotlin `PiiRedactionTest` cases; the payload below is the real one from the capture on +/// #1825, which is how the leak was found. +final class HexPayloadRedactionTests: XCTestCase { + + /// `…WBB5AP0539852…` — a real serial, ASCII inside the payload bytes. + private let payloadWithSerial = "142e1c0001d36e3d1c12a3574242354150303533393835320000" + private let serialAsHex = "4242354150303533393835" + + func testSerialInsideAHexPayloadIsMasked() { + let out = LiveState.redactPii("[event] 0x6D(109) payload=\(payloadWithSerial)") + XCTAssertFalse(out.contains(serialAsHex), "serial must not survive as hex: \(out)") + XCTAssertTrue(out.contains("142e1c0001d36e3d1c12a3"), + "the non-serial bytes are the reason the dump exists: \(out)") + } + + /// Not keyed on a label: this side writes the same dumps four different ways. + func testMaskedUnderEveryLabelThisSideUses() { + for line in ["frame=\(payloadWithSerial)", + "[raw \(payloadWithSerial)]", + "(raw \(payloadWithSerial))", + "raw frame (#900) \(payloadWithSerial)", + payloadWithSerial] { + XCTAssertFalse(LiveState.redactPii(line).contains(serialAsHex), + "serial survived under this label: \(line)") + } + } + + /// The run regex matches consecutive hex characters, so a dump abutting other hex-valid text gives + /// an ODD-length match. Bailing on that returned the serial unredacted while Kotlin masked it — the + /// two halves of one rule must not disagree about which lines are safe. + func testOddLengthRunStillMasksTheSerial() { + let out = LiveState.redactPii("payload=\(payloadWithSerial)f") // 1 trailing half-byte + XCTAssertFalse(out.contains(serialAsHex), "odd-length run left the serial exposed: \(out)") + } + + /// The Kotlin rule is a regex over the printable projection (`[A-Za-z][0-9A-Za-z]{8,}`), so it finds + /// a serial ANYWHERE inside an alphanumeric run. Testing only the run's first byte left the serial + /// exposed when a digit preceded it with no separator — masked on Android, printed on Apple. + func testSerialIsMaskedEvenWhenDigitsPrecedeItInTheSameRun() { + // "0123" + "WBB5AP0539852" as one unbroken alphanumeric run, then a NUL to close it. + let hex = "3031323357424235415030353339383532" + "00" + "0000000000000000" + let out = LiveState.redactPii("payload=\(hex)") + XCTAssertFalse(out.contains(serialAsHex), "digit-prefixed run left the serial exposed: \(out)") + XCTAssertTrue(out.contains("30313233"), "the non-serial digits must survive: \(out)") + } + + func testPayloadWithNoAsciiRunIsUntouched() { + // The charging payloads from the same capture carry no ASCII run — they must pass through whole. + for p in ["707d0000707d0000", "b87e0000b87e0000", "0000000000000000"] { + XCTAssertEqual(LiveState.redactPii("payload=\(p)"), "payload=\(p)") + } + } + + /// The existing text rules must keep working — the hex pass runs first and must not eat them. + func testTextRulesStillApply() { + XCTAssertEqual(LiveState.redactPii("connecting to A1:B2:C3:D4:E5:F6"), + "connecting to A1:••:••:••:••:F6") + XCTAssertTrue(LiveState.redactPii("Discovered WHOOP 4A2B9C1D").contains("")) + } + + /// A vendor/service UUID's longest contiguous hex run is 12 characters, below the 16 the rule needs, + /// so a UUID cannot be partially masked into something unreadable. + func testServiceUuidIsNotTouched() { + let uuid = "61080003-8d6d-82b8-614a-1c8cb0f8dcc6" + XCTAssertTrue(LiveState.redactPii("Notify active \(uuid)").contains("8d6d")) + } + + /// Odd-length or non-hex content returns unchanged rather than throwing: a redactor that throws + /// withholds the entire line. + func testMalformedHexIsLeftAlone() { + XCTAssertEqual(LiveState.redactPii("payload=zzzzzzzzzzzzzzzzzz"), "payload=zzzzzzzzzzzzzzzzzz") + } +} diff --git a/StrandTests/HrTraceTests.swift b/StrandTests/HrTraceTests.swift new file mode 100644 index 0000000000..490057200d --- /dev/null +++ b/StrandTests/HrTraceTests.swift @@ -0,0 +1,181 @@ +import XCTest + +/// Swift twin of the Kotlin `HrTraceTest` (#1957). +/// +/// These exist to keep the two platforms reading the same heart, not merely to check Swift. Every case +/// below has a Kotlin counterpart asserting the same thing, so a change to the retention rule, the tick +/// choices or the normalisation that lands on one side alone fails here. +/// +/// The degenerate cases carry the weight, because they are ordinary on a home screen rather than exotic: +/// a widget added mid-afternoon has one point, and a resting arm holds one bpm for minutes. +final class HrTraceTests: XCTestCase { + + private func series(_ pairs: [(Int64, Int)]) -> [HrPoint] { + pairs.map { HrPoint(ts: $0.0, bpm: $0.1) } + } + + // MARK: retention + + func testOnePointPerMinuteBucketNewestWins() { + var s = HrTrace.append([], ts: 600, bpm: 60) + s = HrTrace.append(s, ts: 620, bpm: 64) // same bucket + s = HrTrace.append(s, ts: 660, bpm: 70) // next bucket + XCTAssertEqual(s.count, 2) + XCTAssertEqual(s[0].bpm, 64) // newest within the bucket won + XCTAssertEqual(s[0].ts, 600) // stamped at the bucket, not the sample + XCTAssertEqual(s[1].bpm, 70) + } + + /// The headline number is the latest bpm; a trace ending on an average would disagree with it. + func testLastPointEqualsLatestReading() { + var s: [HrPoint] = [] + for i in 0...5 { s = HrTrace.append(s, ts: 600 + Int64(i) * 60, bpm: 60 + i) } + XCTAssertEqual(HrTrace.stats(s)?.latest, 65) + XCTAssertEqual(s.last?.bpm, 65) + } + + func testReadingsOlderThanTheWindowFallOff() { + let now: Int64 = 100_000 + let s = series([(now - HrTrace.windowSec - 60, 50), (now - 60, 70)]) + let kept = HrTrace.prune(s, nowSec: now) + XCTAssertEqual(kept.count, 1) + XCTAssertEqual(kept[0].bpm, 70) + } + + /// A clock that jumps backwards must not grow the series without bound. + func testPointCapHoldsUnderABackwardsClock() { + var s: [HrPoint] = [] + for i in 0..<(HrTrace.maxPoints + 50) { + s = HrTrace.append(s, ts: Int64(i) * 60, bpm: 60, nowSec: 0) + } + XCTAssertLessThanOrEqual(s.count, HrTrace.maxPoints) + } + + func testNonPositiveBpmIsNotRecorded() { + XCTAssertTrue(HrTrace.append([], ts: 600, bpm: 0).isEmpty) + } + + // MARK: geometry + + func testPointsSpanTheBoxAndFlipBpmUpward() { + let pts = HrTrace.points(series([(0, 60), (60, 80)]), width: 100, height: 50) + XCTAssertEqual(pts[0].x, 0, accuracy: 0.001) + XCTAssertEqual(pts[1].x, 100, accuracy: 0.001) + XCTAssertEqual(pts[0].y, 50, accuracy: 0.001) // the LOW bpm sits at the BOTTOM + XCTAssertEqual(pts[1].y, 0, accuracy: 0.001) + } + + /// A resting arm holds one bpm for minutes. Zero range must not divide, and must not read as a + /// flatlined or maxed-out heart, so the line runs along the middle. + func testFlatSeriesDrawsDownTheMiddle() { + let pts = HrTrace.points(series([(0, 60), (60, 60), (120, 60)]), width: 90, height: 40) + XCTAssertEqual(pts.count, 3) + for p in pts { XCTAssertEqual(p.y, 20, accuracy: 0.001) } + } + + /// A widget added mid-afternoon has exactly one point until the next minute ticks. + func testSinglePointSitsAtTheLeftEdge() { + let pts = HrTrace.points(series([(600, 61)]), width: 100, height: 50) + XCTAssertEqual(pts.count, 1) + XCTAssertEqual(pts[0].x, 0, accuracy: 0.001) + XCTAssertFalse(pts[0].y.isNaN) + } + + func testEmptySeriesOrZeroBoxDrawsNothing() { + XCTAssertTrue(HrTrace.points([], width: 100, height: 50).isEmpty) + XCTAssertTrue(HrTrace.points(series([(0, 60)]), width: 0, height: 50).isEmpty) + XCTAssertTrue(HrTrace.points(series([(0, 60)]), width: 100, height: 0).isEmpty) + } + + // MARK: ticks + + func testBpmTicksRunMaxMiddleMin() { + XCTAssertEqual(HrTrace.bpmTicks(HrTrace.Stats(min: 59, max: 84, latest: 69)), [84, 72, 59]) + } + + /// Three copies of one number is the honest reading of a steady heart. The VIEW declines to draw a + /// scale in that case; the rule stays here so both platforms agree on what the labels would be. + func testFlatSeriesLabelsOneNumberThreeTimes() { + XCTAssertEqual(HrTrace.bpmTicks(HrTrace.Stats(min: 60, max: 60, latest: 60)), [60, 60, 60]) + } + + func testTimeTicksAnchorToTheDataNotTheWindow() { + XCTAssertEqual(HrTrace.timeTicks(series([(1_000, 60), (1_600, 62), (2_200, 64)])), + [1_000, 1_600, 2_200]) + } + + func testTimeTicksDegradeWithoutEnoughSpan() { + XCTAssertEqual(HrTrace.timeTicks([]), []) + XCTAssertEqual(HrTrace.timeTicks(series([(600, 60)])), [600]) + XCTAssertEqual(HrTrace.timeTicks(series([(600, 60), (601, 61)])), [600, 601]) + } + + func testStatsOverAnEmptySeriesIsNil() { + XCTAssertNil(HrTrace.stats([])) + } + + // MARK: the publish gate that keeps the trace alive + + /// `renderedContentChanged` compares bpm, not history, so a steady heart produced no publish and + /// therefore no new point — the trace would stop advancing at rest and prune to empty. This is the + /// rule that asks for a write anyway, and it must fire exactly when a new minute bucket opens. + func testTraceNeedsPointWhenTheBucketRolls() { + let base = Date(timeIntervalSince1970: 1_000_000) + var snap = WidgetSnapshot(recovery: nil, bpm: 70, batteryPct: nil, bonded: true, updated: base) + snap.hrSeries = [HrPoint(ts: Int64(base.timeIntervalSince1970), bpm: 70)] + + XCTAssertFalse(WidgetSnapshot.traceNeedsPoint(previous: snap, bpm: 70, now: base.addingTimeInterval(5)), + "same bucket needs nothing") + XCTAssertTrue(WidgetSnapshot.traceNeedsPoint(previous: snap, bpm: 70, now: base.addingTimeInterval(61)), + "a new bucket wants its point even though bpm is unchanged") + } + + func testTraceNeedsNoPointWithoutAReading() { + let now = Date(timeIntervalSince1970: 1_000_000) + XCTAssertFalse(WidgetSnapshot.traceNeedsPoint(previous: nil, bpm: nil, now: now)) + XCTAssertFalse(WidgetSnapshot.traceNeedsPoint(previous: nil, bpm: 0, now: now)) + XCTAssertTrue(WidgetSnapshot.traceNeedsPoint(previous: nil, bpm: 70, now: now), + "no stored series at all means the first reading is wanted") + } + + + // MARK: the age rule on the headline number + + /// Twin of the Kotlin `HrDisplay`. Without it the card contradicted itself once the trace began + /// pruning: an empty chart under a confident number, from a reading hours old. + func testAFreshReadingIsShownAndNotDimmed() { + let now = Date(timeIntervalSince1970: 1_000_000) + let r = HrDisplay.resolve(bpm: 70, newestPointTs: Int64(now.timeIntervalSince1970) - 30, now: now) + XCTAssertEqual(r.bpm, 70) + XCTAssertFalse(r.stale) + } + + func testACarriedReadingIsShownButDimmed() { + let now = Date(timeIntervalSince1970: 1_000_000) + let ts = Int64(now.timeIntervalSince1970 - HrDisplay.liveWindow - 30) + let r = HrDisplay.resolve(bpm: 70, newestPointTs: ts, now: now) + XCTAssertEqual(r.bpm, 70) + XCTAssertTrue(r.stale, "past the live window it is carried over, not current") + } + + func testAnAncientReadingIsDroppedEntirely() { + let now = Date(timeIntervalSince1970: 1_000_000) + let ts = Int64(now.timeIntervalSince1970 - HrDisplay.staleCap - 60) + XCTAssertNil(HrDisplay.resolve(bpm: 70, newestPointTs: ts, now: now).bpm) + } + + /// A widget added this minute has a reading and an empty series. Treating that as stale would blank + /// a widget at the moment it started working. + func testAFirstReadingWithNoTraceYetIsShown() { + let now = Date(timeIntervalSince1970: 1_000_000) + let r = HrDisplay.resolve(bpm: 70, newestPointTs: nil, now: now) + XCTAssertEqual(r.bpm, 70) + XCTAssertFalse(r.stale) + } + + func testNoReadingShowsNothing() { + let now = Date(timeIntervalSince1970: 1_000_000) + XCTAssertNil(HrDisplay.resolve(bpm: nil, newestPointTs: nil, now: now).bpm) + XCTAssertNil(HrDisplay.resolve(bpm: 0, newestPointTs: nil, now: now).bpm) + } +} diff --git a/StrandTests/IntelligenceSleepDetectNoNightTests.swift b/StrandTests/IntelligenceSleepDetectNoNightTests.swift index 8097f5588c..80ea9a6f2d 100644 --- a/StrandTests/IntelligenceSleepDetectNoNightTests.swift +++ b/StrandTests/IntelligenceSleepDetectNoNightTests.swift @@ -1,4 +1,5 @@ import XCTest +import StrandAnalytics @testable import Strand /// Pins the "HR tracked but no sleep" diagnostic (#1244). When a day clears the >=200-HR gate yet the @@ -18,25 +19,89 @@ final class IntelligenceSleepDetectNoNightTests: XCTestCase { // gate the night → nothing stages. `window=54h` is the past-day span (30 h back → next midnight). let line = IE.sleepDetectNoNightLogLine( day: "2026-08-11", hrCount: 41230, rrCount: 0, respCount: 880, - gravCount: 0, stepCount: 12, providedCount: 0, windowHours: 54) + gravCount: 0, stepCount: 12, providedCount: 0, windowHours: 54, skinCount: 0) XCTAssertEqual(line, "sleep-detect day=2026-08-11 NO-NIGHT hr=41230 rr=0 resp=880 " - + "grav=0 steps=12 provided=0 window=54h") + // reason=no-motion is the point of this fixture: grav=0 means the stager has no HR-only + // fallback, so no quantity of HR could have staged a night. That is a strap capability limit, + // and it wants a different follow-up from a night that had motion and still staged nothing. + + "grav=0 skin=0 steps=12 provided=0 window=54h reason=no-motion") } func testTodayWindowIs48h() { // Today's read caps at dayStart+18h (vs a past day's next-midnight), so the whole span is 48 h. let line = IE.sleepDetectNoNightLogLine( day: "2026-08-12", hrCount: 5000, rrCount: 900, respCount: 300, - gravCount: 4, stepCount: 0, providedCount: 0, windowHours: 48) + gravCount: 4, stepCount: 0, providedCount: 0, windowHours: 48, skinCount: 0) XCTAssertTrue(line.contains("window=48h"), line) + // The other branch: motion WAS present and staging still produced nothing, which is the case + // worth investigating rather than a capability limit. + XCTAssertTrue(line.contains("reason=staged-none"), line) } func testLineCarriesNoEmDash() { // House style: never an em-dash in shared text. let line = IE.sleepDetectNoNightLogLine( day: "2026-08-11", hrCount: 1, rrCount: 1, respCount: 1, - gravCount: 1, stepCount: 1, providedCount: 1, windowHours: 54) + gravCount: 1, stepCount: 1, providedCount: 1, windowHours: 54, skinCount: 1) XCTAssertFalse(line.contains("—")) } + + /// The signal this line was missing. Gravity is a PLAIN read with no truncation counter, so a night + /// clipped of its newest motion staged badly and said nothing about why — and `grav=192698` reads as + /// healthy until you know it is 96% of a cap. A read that comes back AT the limit is truncated, which + /// is what `full.count >= limit` means everywhere else here. + func testAStreamAtItsReadCapIsNamed() { + let line = IntelligenceEngine.sleepDetectNoNightLogLine( + day: "2026-09-06", hrCount: 1000, rrCount: 1000, respCount: 0, + gravCount: StreamReadCap.gravity, stepCount: 0, providedCount: 0, windowHours: 54, skinCount: 0) + XCTAssertTrue(line.contains("atCap=grav"), line) + } + + /// HR and R-R at their caps produce NO marker, and that is deliberate rather than an oversight. + /// + /// Both arrive through `SlidingStreamWindow.rows`, which returns a SLICE of a read spanning more than + /// this night, so a truncated spliced window still hands back a count under the cap — the marker would + /// miss the very case it claims to catch. Their exact truncation count is printed once per pass by + /// `WindowedStreamPlan.logLine` instead. Pinned so the omission is not "fixed" back in. + func testHrAndRrAtTheirCapsCarryNoMarker() { + let line = IntelligenceEngine.sleepDetectNoNightLogLine( + day: "2026-09-06", hrCount: StreamReadCap.hr, rrCount: StreamReadCap.rr, respCount: 0, + gravCount: 10, stepCount: 0, providedCount: 0, windowHours: 54, skinCount: 0) + XCTAssertFalse(line.contains("atCap"), line) + } + + /// A healthy night says nothing extra — the marker only appears when something actually clipped. + func testANightUnderTheCapsCarriesNoMarker() { + let line = IntelligenceEngine.sleepDetectNoNightLogLine( + day: "2026-09-06", hrCount: 192_698, rrCount: 136_285, respCount: 0, + gravCount: 192_698, stepCount: 0, providedCount: 0, windowHours: 54, skinCount: 0) + XCTAssertFalse(line.contains("atCap"), line) + } + + /// The field capture that motivated the caps: 192,698 gravity rows was 96% of the OLD 200,000 limit + /// and silent. Under the caps this ships with, the same night is comfortably clear. + func testTheMeasuredFieldNightIsClearOfTheCaps() { + XCTAssertLessThan(192_698, StreamReadCap.gravity) + } + + /// The count that could not be measured. Skin temp only appears in a Test Centre "Night" line, which + /// fires when a session EXISTS — so on the nights being triaged, the ones with no sleep at all, its + /// volume was invisible. + func testTheLineReportsTheSkinSampleCount() { + let line = IntelligenceEngine.sleepDetectNoNightLogLine( + day: "2026-09-06", hrCount: 1000, rrCount: 900, respCount: 0, gravCount: 0, + stepCount: 0, providedCount: 0, windowHours: 54, skinCount: 4242) + XCTAssertTrue(line.contains("skin=4242"), line) + } + + /// Skin is the stream whose density was never measured, and it was the one `atCap` did not cover + /// when the marker was first written — so a clipped skin read printed a bare count and no warning. + func testSkinAtItsReadCapIsNamed() { + let line = IntelligenceEngine.sleepDetectNoNightLogLine( + day: "2026-09-06", hrCount: 10, rrCount: 10, respCount: 0, gravCount: 10, + stepCount: 0, providedCount: 0, windowHours: 54, skinCount: StreamReadCap.skin) + XCTAssertTrue(line.contains("atCap=skin"), line) + } + } diff --git a/StrandTests/KeyMetricPrefsDecodeTests.swift b/StrandTests/KeyMetricPrefsDecodeTests.swift index 99b5af1148..c6be7ce157 100644 --- a/StrandTests/KeyMetricPrefsDecodeTests.swift +++ b/StrandTests/KeyMetricPrefsDecodeTests.swift @@ -44,4 +44,17 @@ final class KeyMetricPrefsDecodeTests: XCTestCase { XCTAssertEqual(KeyMetricPrefs.decodeEnabled(" "), KeyMetric.defaultOrder) XCTAssertEqual(KeyMetricPrefs.decodeEnabled(""), KeyMetric.defaultOrder) } + + /// Queue 11c follow-up (2026-08-24): `.skinTemp` is a NEW persisted case (Skin Temp was already a + /// "Your Cards" `DashboardCard` option, never a Key Metrics one). Pins the two contract points that + /// matter for adding a case without disturbing anyone's saved layout: the raw token round-trips + /// ("skinTemp", byte-identical to Kotlin's `KeyMetric.SKIN_TEMP.raw`), and it does NOT join + /// `defaultOrder` — a fresh install's default (and an existing user's saved layout) stays + /// byte-identical to before this case existed. + func testSkinTempRawTokenRoundTripsAndIsNotInDefaultOrder() { + XCTAssertEqual(KeyMetric(rawValue: "skinTemp"), .skinTemp) + XCTAssertEqual(KeyMetric.skinTemp.rawValue, "skinTemp") + XCTAssertFalse(KeyMetric.defaultOrder.contains(.skinTemp)) + XCTAssertFalse(KeyMetricPrefs.decodeEnabled("").contains(.skinTemp)) + } } diff --git a/StrandTests/LastSyncAttributionTests.swift b/StrandTests/LastSyncAttributionTests.swift new file mode 100644 index 0000000000..df4add5e28 --- /dev/null +++ b/StrandTests/LastSyncAttributionTests.swift @@ -0,0 +1,83 @@ +import XCTest +@testable import Strand + +/// Pins last-sync attribution. Kotlin twin: `LastSyncAttributionTest`. +/// +/// Reported three times before it was believed, which is the part worth keeping: the number was +/// plausible, so the reading was treated as mistaken rather than the label. The capture had +/// "Last sync: 4d ago" beside zero banked rows for the active 5/MG, and a 4.0 last seen three days +/// earlier — the timestamp on the 5/MG's screen belonged to the other strap. +final class LastSyncAttributionTests: XCTestCase { + + func testThisStrapsOwnStampAlwaysWins() { + XCTAssertEqual(LastSyncAttribution.resolve(perDevice: 500, legacyGlobal: 900, pairedCount: 1), 500) + XCTAssertEqual(LastSyncAttribution.resolve(perDevice: 500, legacyGlobal: 900, pairedCount: 3), 500) + } + + /// The single-strap upgrade path. The global key is unattributed, but with one strap paired there is + /// only one strap it can have come from — so it reads correctly across the upgrade instead of + /// resetting to "never" for everyone. + func testTheLegacyGlobalIsHonouredOnlyWhenOneStrapCouldHaveWrittenIt() { + XCTAssertEqual(LastSyncAttribution.resolve(perDevice: nil, legacyGlobal: 900, pairedCount: 1), 900) + XCTAssertNil(LastSyncAttribution.resolve(perDevice: nil, legacyGlobal: 900, pairedCount: 2)) + } + + /// THE case from the capture. Two straps, and the active one has never synced: the honest answer is + /// "never", not the other strap's timestamp. Not a degraded answer — the correct one. + func testAStrapThatHasNeverSyncedSaysSoEvenWhenAnotherHas() { + XCTAssertNil(LastSyncAttribution.resolve(perDevice: nil, legacyGlobal: 1_787_000_000, pairedCount: 2)) + } + + /// Zero is "never recorded", not a timestamp — the defaults API returns 0.0 for a missing Double, so + /// treating it as a value would date every strap to 1970. + func testZeroAndNilAreBothAbsent() { + XCTAssertNil(LastSyncAttribution.resolve(perDevice: 0, legacyGlobal: 0, pairedCount: 1)) + XCTAssertNil(LastSyncAttribution.resolve(perDevice: nil, legacyGlobal: nil, pairedCount: 1)) + XCTAssertEqual(LastSyncAttribution.resolve(perDevice: 0, legacyGlobal: 900, pairedCount: 1), 900) + } + + /// A zero-paired registry must not license the global. The count is the evidence that exactly one + /// strap could have written it; "no straps" is not that evidence, and treating it as such would let a + /// failed registry read resurrect the bug. + func testAnEmptyRegistryDoesNotLicenseTheGlobal() { + XCTAssertNil(LastSyncAttribution.resolve(perDevice: nil, legacyGlobal: 900, pairedCount: 0)) + } + + func testThePrefKeyIsPerDeviceAndCaseInsensitive() { + XCTAssertEqual(LastSyncAttribution.prefKey(peripheralId: "F1:D4:F7:24:53:DE"), + "noop.lastSyncAt.f1:d4:f7:24:53:de") + XCTAssertEqual(LastSyncAttribution.prefKey(peripheralId: "f1:d4:f7:24:53:de"), + LastSyncAttribution.prefKey(peripheralId: "F1:D4:F7:24:53:DE")) + XCTAssertNil(LastSyncAttribution.prefKey(peripheralId: nil)) + XCTAssertNil(LastSyncAttribution.prefKey(peripheralId: " ")) + } + + /// It must not collide with the firmware key, which is built the same way from the same identifier. + func testItDoesNotCollideWithTheFirmwareKeyForTheSameStrap() { + let id = "f1:d4:f7:24:53:de" + XCTAssertNotEqual(LastSyncAttribution.prefKey(peripheralId: id), + FirmwareAttribution.prefKey(peripheralId: id)) + } + + /// The #57 write-health pair had the identical defect one line below in the same capture: "rows last + /// landed 4d ago" against a strap whose own row count was zero. Both halves are scoped, because they + /// are read as a pair — "stalled more recently than ok" is the alarm — and scoping only one would + /// compare this strap's stall against another strap's success. + func testTheWriteHealthPairIsScopedAndItsHalvesStayDistinct() { + let id = "f1:d4:f7:24:53:de" + XCTAssertEqual(LastSyncAttribution.writeHealthPrefKey(peripheralId: id, kind: "lastWriteOkAt"), + "sync.lastWriteOkAt.f1:d4:f7:24:53:de") + XCTAssertNotEqual(LastSyncAttribution.writeHealthPrefKey(peripheralId: id, kind: "lastWriteOkAt"), + LastSyncAttribution.writeHealthPrefKey(peripheralId: id, kind: "lastWriteStalledAt")) + XCTAssertNil(LastSyncAttribution.writeHealthPrefKey(peripheralId: nil, kind: "lastWriteOkAt")) + XCTAssertNil(LastSyncAttribution.writeHealthPrefKey(peripheralId: " ", kind: "lastWriteOkAt")) + } + + /// Two straps must never share a write-health key, or one strap's successful offload would clear the + /// alarm raised by another strap's stall. + func testTwoStrapsGetDifferentWriteHealthKeys() { + XCTAssertNotEqual( + LastSyncAttribution.writeHealthPrefKey(peripheralId: "aa:bb:cc:dd:ee:ff", kind: "lastWriteOkAt"), + LastSyncAttribution.writeHealthPrefKey(peripheralId: "ff:ee:dd:cc:bb:aa", kind: "lastWriteOkAt")) + } +} diff --git a/StrandTests/LiquidTodayTileValueTests.swift b/StrandTests/LiquidTodayTileValueTests.swift new file mode 100644 index 0000000000..7e019565ee --- /dev/null +++ b/StrandTests/LiquidTodayTileValueTests.swift @@ -0,0 +1,33 @@ +import XCTest +@testable import Strand + +/// Pins the Key Metrics tile's value/unit join (#492, and the `–%` it left behind). +/// +/// The reported symptom was an empty Strain tile reading `–%`: the placeholder kept its unit, so "no data" +/// rendered as something that parses like a negative percentage. Android has always suppressed the unit on +/// its no-data sentinel; these pin the iOS twin so the two cannot drift apart again. +final class LiquidTodayTileValueTests: XCTestCase { + + /// The reported bug, exactly: a missing metric must not carry a unit. + func testNoValuePlaceholderDropsItsUnit() { + XCTAssertEqual(LiquidTodayView.tileDisplayValue(LiquidTodayView.noValueDash, unit: "%"), + LiquidTodayView.noValueDash) + XCTAssertEqual(LiquidTodayView.tileDisplayValue(LiquidTodayView.noValueDash, unit: "kcal"), + LiquidTodayView.noValueDash) + } + + /// A real value still gets its unit, and `%` binds tight where the others take a space — the existing + /// convention, unchanged by the guard above. + func testAValueKeepsItsUnitAndSpacing() { + XCTAssertEqual(LiquidTodayView.tileDisplayValue("93", unit: "%"), "93%") + XCTAssertEqual(LiquidTodayView.tileDisplayValue("1272", unit: "kcal"), "1272 kcal") + } + + /// Effort passes an EMPTY unit now (it is a 0–100 / 0–21 load index, not a percentage), so the value + /// must come through untouched on both the present and the absent path. + func testAnEmptyUnitIsLeftAlone() { + XCTAssertEqual(LiquidTodayView.tileDisplayValue("38.9", unit: ""), "38.9") + XCTAssertEqual(LiquidTodayView.tileDisplayValue(LiquidTodayView.noValueDash, unit: ""), + LiquidTodayView.noValueDash) + } +} diff --git a/StrandTests/NightDetailDebtFormattingTests.swift b/StrandTests/NightDetailDebtFormattingTests.swift new file mode 100644 index 0000000000..f7612ca5fc --- /dev/null +++ b/StrandTests/NightDetailDebtFormattingTests.swift @@ -0,0 +1,33 @@ +import XCTest +import SwiftUI +@testable import StrandDesign +@testable import Strand + +final class NightDetailDebtFormattingTests: XCTestCase { + func testTenMinuteDebtBoundary() { + XCTAssertEqual(nightDetailDebtCaption(9.9), String(localized: "On target")) + assertSameResolvedColor(nightDetailDebtColor(9.9), StrandPalette.statusPositive) + + XCTAssertEqual(nightDetailDebtCaption(10.0), String(localized: "Below need")) + assertSameResolvedColor(nightDetailDebtColor(10.0), StrandPalette.statusWarning) + } + + func testImportedDebtAboveBoundaryRemainsDebt() { + XCTAssertEqual(nightDetailDebtCaption(12.5), String(localized: "Below need")) + assertSameResolvedColor(nightDetailDebtColor(12.5), StrandPalette.statusWarning) + } + + private func assertSameResolvedColor( + _ actual: SwiftUI.Color, + _ expected: SwiftUI.Color, + file: StaticString = #filePath, + line: UInt = #line + ) { + let actualRGBA = actual.rgbaComponents + let expectedRGBA = expected.rgbaComponents + XCTAssertEqual(actualRGBA.r, expectedRGBA.r, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actualRGBA.g, expectedRGBA.g, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actualRGBA.b, expectedRGBA.b, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actualRGBA.a, expectedRGBA.a, accuracy: 0.001, file: file, line: line) + } +} diff --git a/StrandTests/OnboardingUnitsPickerTests.swift b/StrandTests/OnboardingUnitsPickerTests.swift index 5ad0bab4ef..05c15ea5f0 100644 --- a/StrandTests/OnboardingUnitsPickerTests.swift +++ b/StrandTests/OnboardingUnitsPickerTests.swift @@ -14,6 +14,7 @@ final class OnboardingUnitsPickerTests: XCTestCase { /// or the choice made in onboarding wouldn't reach the Weight/Height display. func testUnitSystemKeyIsTheSharedAppStorageKey() { XCTAssertEqual(UnitPrefs.systemKey, "units.system") + XCTAssertEqual(UnitPrefs.distanceSystemKey, "units.distance") } /// The picker tags are the `UnitSystem` rawValues; they must round-trip through the same initializer diff --git a/StrandTests/OrphanedSamplesLineTests.swift b/StrandTests/OrphanedSamplesLineTests.swift index efd7a5b82a..f5f49617f8 100644 --- a/StrandTests/OrphanedSamplesLineTests.swift +++ b/StrandTests/OrphanedSamplesLineTests.swift @@ -45,4 +45,62 @@ final class OrphanedSamplesLineTests: XCTestCase { ) XCTAssertTrue(line.contains("'whoop-aa' (50 rows), 'whoop-zz' (50 rows)")) } + + // MARK: - #1193 wording is for a genuine split — NOT for a second strap's night + + /// Both over-assertions this branch has carried. It must not call a second strap's night a read + /// failure — `DayOwnerResolver` hands each day to whichever device holds its data, so samples under + /// that id can be perfectly normal. And it must not call the silence expected either: with a 4.0 and + /// a 5.0 worn together the active strap can bank nothing because its handshake never completed + /// (#1635), which looks identical from here. The line states both halves; this pins that it keeps + /// stating both. + func testASecondRegisteredStrapsNightStatesTheForkNeverABareVerdict() { + let line = DebugDataDiagnostics.orphanedSamplesLine( + activeId: "whoop-FD:4A", + othersWithSamples: [("my-whoop", 59_304)], + otherLiveStrapIds: ["my-whoop"]) + XCTAssertTrue(line.contains("another registered strap")) + XCTAssertTrue(line.contains("'my-whoop' (59304 rows)")) + XCTAssertTrue(line.contains("dayOwner"), "must point at the line that settles it") + XCTAssertFalse(line.contains("are not being read"), "must not claim a bug") + XCTAssertFalse(line.contains("#1193")) + // #1635 dual-wear: it may NOT declare the silence normal outright. Wearing a 4.0 and a 5.0 + // together, the other strap's rows are present while the ACTIVE one banked nothing because its + // handshake never completed — and this function cannot tell that from a single-strap night. + XCTAssertTrue(line.contains("If you wore BOTH"), "must state the both-straps half") + XCTAssertTrue(line.contains("sync is what to check"), "and name the sync as what to check") + XCTAssertFalse(line.contains("OWNED by that strap"), "must not assert one-strap ownership") + } + + /// An id that is NOT a live registered strap is still the #1193 split — that wording must survive. + func testAnUnregisteredIdHoldingTheSamplesIsStillReportedAsASplit() { + let line = DebugDataDiagnostics.orphanedSamplesLine( + activeId: "whoop-FD:4A", + othersWithSamples: [("my-whoop", 59_304)], + otherLiveStrapIds: ["whoop-OTHER"]) + XCTAssertTrue(line.contains("#1193")) + XCTAssertTrue(line.contains("are not being read")) + } + + /// No registry supplied (the default) keeps every existing caller byte-identical. + func testWithoutARegistryTheWordingIsUnchanged() { + let withDefault = DebugDataDiagnostics.orphanedSamplesLine( + activeId: "whoop-FD:4A", othersWithSamples: [("my-whoop", 59_304)]) + let explicitEmpty = DebugDataDiagnostics.orphanedSamplesLine( + activeId: "whoop-FD:4A", othersWithSamples: [("my-whoop", 59_304)], otherLiveStrapIds: []) + XCTAssertEqual(withDefault, explicitEmpty) + XCTAssertTrue(withDefault.contains("#1193")) + } + + /// Mixed: one id is a live strap, one is not — the live strap explains it, so no bug is claimed. + func testALiveStrapAmongTheIdsWinsOverTheSplitWording() { + let line = DebugDataDiagnostics.orphanedSamplesLine( + activeId: "whoop-FD:4A", + othersWithSamples: [("orphan-id", 10), ("my-whoop", 59_304)], + otherLiveStrapIds: ["my-whoop"]) + XCTAssertTrue(line.contains("another registered strap")) + XCTAssertTrue(line.contains("'my-whoop' (59304 rows)")) + XCTAssertFalse(line.contains("'orphan-id'"), + "the orphan id is not the story when a real strap owns the night") + } } diff --git a/StrandTests/PiiRedactionTests.swift b/StrandTests/PiiRedactionTests.swift new file mode 100644 index 0000000000..6e9691bda3 --- /dev/null +++ b/StrandTests/PiiRedactionTests.swift @@ -0,0 +1,126 @@ +import XCTest +@testable import Strand + +/// The account holder's NAME in the shareable strap log (#445). +/// +/// WHOOP names a strap "'s Whoop" by default, and the scan path logs that advertised name on +/// every discovery, so the file we ask people to attach to public issues carried a real person's name. +/// None of the other rules in `LiveState.redactPii` could see it: they key on MAC shape, a "WHOOP " + +/// digit serial, or a "whoop-" id. +/// +/// Twin of the Kotlin `PiiRedactionTest` name cases, same inputs and same expected text, so the two +/// platforms cannot redact a log differently. +final class PiiRedactionTests: XCTestCase { + + func testPersonalNameInDiscoveryLineIsRedacted() { + XCTAssertEqual( + LiveState.redactPii("Discovered Ryan's Whoop (rssi -55) - connecting"), + "Discovered 's Whoop (rssi -55) - connecting") + } + + /// Apple platforms write U+2019 into default device names, so the straight quote is not enough — + /// a straight-quote-only rule would miss this platform's own logs. + func testCurlyApostropheNameIsRedacted() { + XCTAssertEqual( + LiveState.redactPii("Discovered Ryan\u{2019}s Whoop (rssi -55)"), + "Discovered \u{2019}s Whoop (rssi -55)") + } + + /// The MODEL after the possessive is diagnostic and identifies nobody, so it must survive. + func testModelSurvivesNameRedaction() { + XCTAssertEqual(LiveState.redactPii("Ryan's WHOOP 4.0"), "'s WHOOP 4.0") + } + + /// The documented gap, pinned so it stays visible rather than assumed: ONE token before the + /// possessive. A multi-token rule cannot tell a name from surrounding log text. + func testMultiTokenNameKeepsTheLeadingToken() { + XCTAssertEqual(LiveState.redactPii("Ryan B's Whoop"), "Ryan 's Whoop") + } + + /// The rule must not touch ids or ordinary text that merely contain "whoop". + func testNameRuleLeavesIdsAndPlainTextAlone() { + XCTAssertEqual(LiveState.redactPii("my-whoop and my-whoop-noop"), "my-whoop and my-whoop-noop") + XCTAssertEqual(LiveState.redactPii("no pii here"), "no pii here") + } + + /// The name never reaches a shared log at all. The sink rule is defence-in-depth; this is the real + /// guarantee, and it is an ALLOWLIST so an unanticipated naming shape is dropped by default. + func testLogSafeDeviceNameKeepsOnlyTheModel() { + XCTAssertEqual(LiveState.logSafeDeviceName("Ryan's Whoop"), " Whoop") + XCTAssertEqual(LiveState.logSafeDeviceName("Ryan B's WHOOP 4.0"), " WHOOP 4.0") + XCTAssertEqual(LiveState.logSafeDeviceName("Ryan\u{2019}s WHOOP 5.0 MG"), " WHOOP 5.0 MG") + } + + /// A fully custom name has no model token to keep, so nothing of it survives. + func testLogSafeDeviceNameDropsAWhollyCustomName() { + XCTAssertEqual(LiveState.logSafeDeviceName("Dad's spare"), "") + XCTAssertEqual(LiveState.logSafeDeviceName("Sarah"), "") + } + + /// "We saw no name" and "we removed a name" are different facts to a reader, so the sentinel stays. + func testLogSafeDeviceNameKeepsTheNoNameSentinel() { + XCTAssertEqual(LiveState.logSafeDeviceName("unknown"), "unknown") + XCTAssertEqual(LiveState.logSafeDeviceName(nil), "unknown") + XCTAssertEqual(LiveState.logSafeDeviceName(" "), "unknown") + } + + + /// Third-party straps: the MODEL is the diagnostic, so a name carrying no person survives intact. + func testLogSafeDeviceNameKeepsAnUnrenamedThirdPartyModel() { + XCTAssertEqual(LiveState.logSafeDeviceName("Polar H10"), "Polar H10") + XCTAssertEqual(LiveState.logSafeDeviceName("TICKR"), "TICKR") + XCTAssertEqual(LiveState.logSafeDeviceName("WHOOP 4.0"), "WHOOP 4.0") + } + + /// ...but a renamed one loses the person and keeps the model. + func testLogSafeDeviceNameStripsThePersonFromARenamedThirdPartyStrap() { + XCTAssertEqual(LiveState.logSafeDeviceName("Ryan's Polar H10"), " Polar H10") + XCTAssertEqual(LiveState.logSafeDeviceName("Sarah TICKR"), " TICKR") + } + + + /// The hole a model-code PATTERN opened, pinned shut. "[a-z]{1,4}\\d{1,3}" was meant to admit "H10" + /// and admitted "Ryan1" and "Sam99" with it, passing a first name through untouched. Model codes are + /// listed one by one for this reason; if a pattern is ever reintroduced, these fail. + func testANameWithDigitsIsNotMistakenForAModelCode() { + XCTAssertEqual(LiveState.logSafeDeviceName("Ryan1"), "") + XCTAssertEqual(LiveState.logSafeDeviceName("Anna2"), "") + XCTAssertEqual(LiveState.logSafeDeviceName("Bob12"), "") + XCTAssertEqual(LiveState.logSafeDeviceName("Sam99 Whoop"), " Whoop") + } + + /// The listed codes still survive, so the tightening did not cost the diagnostic. + func testListedModelCodesStillSurvive() { + XCTAssertEqual(LiveState.logSafeDeviceName("Polar H10"), "Polar H10") + XCTAssertEqual(LiveState.logSafeDeviceName("Polar OH1"), "Polar OH1") + } + + + /// Field evidence (a 2026-09-05 staging log): serials beginning with a LETTER walked straight through + /// the old rule, which demanded a digit after "WHOOP ". Both spellings must mask. + func testMasksASerialWhateverCharacterItStartsWith() { + XCTAssertEqual(LiveState.redactPii("Discovered WHOOP MGB0779473 (rssi -48) - connecting"), + "Discovered WHOOP (rssi -48) - connecting") + XCTAssertEqual(LiveState.redactPii("Discovered WHOOP 4C1594026 (rssi -63)"), + "Discovered WHOOP (rssi -63)") + XCTAssertEqual(LiveState.redactPii("WHOOP 5AG0393796"), "WHOOP ") + } + + /// The digit requirement earns its keep here. "WHOOP PUFFIN service 1150" is a real diagnostic line + /// from the same log, and PUFFIN is six alnum characters - a length-only rule would mask it and + /// destroy the meaning of the line. + func testDoesNotMaskAWhoopPrefixedWord() { + let line = "WHOOP PUFFIN service 1150 detected but unsupported" + XCTAssertEqual(LiveState.redactPii(line), line) + } + + /// A bare serial with no "WHOOP " in front of it is NOT covered here, and deliberately so: the only + /// rule that could catch it keys on shape alone and would mask ordinary prose. Discovery lines - + /// where this actually occurred - are handled at the call site by `logSafeDeviceName` instead. + func testABareSerialIsLeftToTheCallSiteNotGuessedAtHere() { + XCTAssertEqual(LiveState.redactPii("Discovered WBB5BP1174092 (rssi -64)"), + "Discovered WBB5BP1174092 (rssi -64)") + XCTAssertEqual(LiveState.logSafeDeviceName("WBB5BP1174092"), "") + } + +} diff --git a/StrandTests/RawDataCollectorParityTests.swift b/StrandTests/RawDataCollectorParityTests.swift new file mode 100644 index 0000000000..f00a0703d2 --- /dev/null +++ b/StrandTests/RawDataCollectorParityTests.swift @@ -0,0 +1,57 @@ +import XCTest +@testable import Strand + +/// Feature-level parity guard for the Android and Apple raw-data collectors. The shared oracle lists +/// every user-visible/session-lifecycle capability and source markers on both implementations. +final class RawDataCollectorParityTests: XCTestCase { + private struct Oracle: Decodable { + let schemaVersion: Int + let capabilities: [String: Capability] + enum CodingKeys: String, CodingKey { case schemaVersion = "schema_version", capabilities } + } + private struct Capability: Decodable { let swift: [String]; let kotlin: [String] } + + private var repoRoot: URL { + URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + } + + private func oracleData() throws -> Data { + let url = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "raw_data_collector_parity", + withExtension: "json")) + return try Data(contentsOf: url) + } + + func testAppleSurfaceStillImplementsEveryDeclaredCapability() throws { + let oracle = try JSONDecoder().decode(Oracle.self, from: oracleData()) + XCTAssertEqual(oracle.schemaVersion, 1) + let paths = [ + "Strand/Collect/RawDataSessionStore.swift", "Strand/Collect/Collector.swift", + "Strand/BLE/BLEManager.swift", "Strand/Screens/RawDataCollectorView.swift", + "Strand/Collect/ImuSessionFileStore.swift", + "Packages/WhoopStore/Sources/WhoopStore/Database.swift", + "Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift", + "Packages/WhoopStore/Sources/WhoopStore/RawOutbox.swift", + ] + let source = try paths.map { try String(contentsOf: repoRoot.appendingPathComponent($0)) } + .joined(separator: "\n") + for (name, capability) in oracle.capabilities { + for marker in capability.swift { + XCTAssertTrue(source.contains(marker), "Apple collector lost \(name) marker: \(marker)") + } + } + } + + func testAndroidAndAppleOracleCopiesAreByteIdentical() throws { + let android = repoRoot.appendingPathComponent("android/app/src/test/resources/raw_data_collector_parity.json") + XCTAssertEqual(try oracleData(), try Data(contentsOf: android), + "Raw-data collector parity oracle copies must change together") + } + + func testAppleKeepsTheOffSessionRealtimeImuFailSafe() throws { + let source = try String(contentsOf: repoRoot.appendingPathComponent("Strand/BLE/BLEManager.swift")) + XCTAssertTrue(source.contains("stopUnexpectedRealtimeImu(frame, isOffload: isOffload)")) + XCTAssertTrue(source.contains("frame[8] == 43 || frame[8] == 51")) + XCTAssertTrue(source.contains("send(.stopRawData, payload: [0x01]")) + XCTAssertTrue(source.contains("send(.toggleIMUMode, payload: [0x01, 0x00]")) + } +} diff --git a/StrandTests/RawDataSessionStoreTests.swift b/StrandTests/RawDataSessionStoreTests.swift new file mode 100644 index 0000000000..38c2c54714 --- /dev/null +++ b/StrandTests/RawDataSessionStoreTests.swift @@ -0,0 +1,158 @@ +import XCTest +@testable import Strand +import WhoopStore + +@MainActor +final class RawDataSessionStoreTests: XCTestCase { + private func temporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: url) } + return url + } + + func testRawSessionLifecycleAndReload() throws { + let directory = try temporaryDirectory() + let store = RawDataSessionStore(directory: directory) + XCTAssertNotNil(store.start(deviceId: "strap", now: Date(timeIntervalSince1970: 100))) + store.stop(now: Date(timeIntervalSince1970: 106)) + + let session = try XCTUnwrap(store.sessions.first) + XCTAssertEqual(session.endedAtMs, 106_000) + XCTAssertEqual(session.events.map(\.kind), ["start", "stop"]) + + let reloaded = RawDataSessionStore(directory: directory) + XCTAssertEqual(reloaded.sessions.first, session) + } + + func testMarkersCanBeAddedEditedDeletedAndExported() throws { + let store = RawDataSessionStore(directory: try temporaryDirectory()) + let session = try XCTUnwrap(store.start(deviceId: "strap", now: Date(timeIntervalSince1970: 100))) + let marker = try XCTUnwrap(store.addMarker(sessionId: session.id, + at: Date(timeIntervalSince1970: 102), type: "issue", text: "Bluetooth off")) + store.updateMarker(sessionId: session.id, markerId: try XCTUnwrap(marker.markerId), + at: Date(timeIntervalSince1970: 103), type: "moment", text: "Recovered") + store.stop(now: Date(timeIntervalSince1970: 106)) + + let updated = try XCTUnwrap(store.sessions.first) + let saved = try XCTUnwrap(updated.events.first(where: { $0.kind == "marker" })) + XCTAssertEqual(saved.atMs, 103_000) + XCTAssertEqual(saved.markerType, "moment") + XCTAssertEqual(saved.text, "Recovered") + + let events = try XCTUnwrap(store.exportEntries(for: updated) + .first(where: { $0.name == "events.jsonl" })) + XCTAssertTrue(String(decoding: events.data, as: UTF8.self).contains("\"marker_type\":\"moment\"")) + let meta = try XCTUnwrap(store.exportEntries(for: updated) + .first(where: { $0.name == "meta.json" })) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: meta.data) as? [String: Any]) + let markers = try XCTUnwrap(object["markers"] as? [[String: Any]]) + XCTAssertEqual(markers.first?["type"] as? String, "moment") + + store.deleteMarker(sessionId: session.id, markerId: try XCTUnwrap(saved.markerId)) + XCTAssertFalse(try XCTUnwrap(store.sessions.first).events.contains(where: { $0.kind == "marker" })) + } + + func testMarkerTimeIsClampedToSession() throws { + let store = RawDataSessionStore(directory: try temporaryDirectory()) + let session = try XCTUnwrap(store.start(deviceId: "strap", now: Date(timeIntervalSince1970: 100))) + store.stop(now: Date(timeIntervalSince1970: 110)) + let marker = try XCTUnwrap(store.addMarker(sessionId: session.id, + at: Date(timeIntervalSince1970: 200), type: "end", text: "")) + XCTAssertEqual(marker.atMs, 110_000) + } + + func testEditedRangeFiltersExportWithoutDestroyingMarkers() throws { + let store = RawDataSessionStore(directory: try temporaryDirectory()) + let session = try XCTUnwrap(store.start(deviceId: "strap", now: Date(timeIntervalSince1970: 100))) + _ = store.addMarker(sessionId: session.id, at: Date(timeIntervalSince1970: 102), + type: "start", text: "outside") + _ = store.addMarker(sessionId: session.id, at: Date(timeIntervalSince1970: 105), + type: "moment", text: "inside") + store.stop(now: Date(timeIntervalSince1970: 110)) + store.setRange(sessionId: session.id, from: Date(timeIntervalSince1970: 104), + to: Date(timeIntervalSince1970: 108)) + + let edited = try XCTUnwrap(store.sessions.first) + XCTAssertEqual(edited.events.filter { $0.kind == "marker" }.count, 2) + let events = try XCTUnwrap(store.exportEntries(for: edited) + .first(where: { $0.name == "events.jsonl" })) + let text = String(decoding: events.data, as: UTF8.self) + XCTAssertFalse(text.contains("outside")) + XCTAssertTrue(text.contains("inside")) + } + + func testExportReportsSensorAvailabilityFromAssembledPayload() throws { + let store = RawDataSessionStore(directory: try temporaryDirectory()) + _ = store.start(deviceId: "strap", now: Date(timeIntervalSince1970: 100)) + store.stop(now: Date(timeIntervalSince1970: 110)) + let session = try XCTUnwrap(store.sessions.first) + let meta = try XCTUnwrap(store.exportEntries(for: session, sensorAvailable: true) + .first(where: { $0.name == "meta.json" })) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: meta.data) as? [String: Any]) + XCTAssertEqual(object["sensor_export_available"] as? Bool, true) + XCTAssertFalse(store.exportEntries(for: session).contains { $0.name == "raw-frames.jsonl" }) + } + + func testFullSecondBoundsExcludePartialEndpointSeconds() { + let bounds = RawDataCollectorView.fullSecondBounds(fromMs: 100_001, toMs: 104_999) + XCTAssertEqual(bounds?.from, 101) + XCTAssertEqual(bounds?.to, 103) + XCTAssertNil(RawDataCollectorView.fullSecondBounds(fromMs: 100_001, toMs: 100_999)) + } + + func testMarkerCurrentTimeTicksOnlyForActiveSession() throws { + let store = RawDataSessionStore(directory: try temporaryDirectory()) + _ = store.start(deviceId: "strap", now: Date(timeIntervalSince1970: 100)) + let active = try XCTUnwrap(store.sessions.first) + XCTAssertEqual(RawDataCollectorView.markerCurrentTime(session: active, + now: Date(timeIntervalSince1970: 105)).timeIntervalSince1970, 105) + store.stop(now: Date(timeIntervalSince1970: 110)) + let stopped = try XCTUnwrap(store.sessions.first) + XCTAssertEqual(RawDataCollectorView.markerCurrentTime(session: stopped, + now: Date(timeIntervalSince1970: 999)).timeIntervalSince1970, 110) + } + + func testActiveSessionCannotBeDeleted() throws { + let store = RawDataSessionStore(directory: try temporaryDirectory()) + let active = try XCTUnwrap(store.start(deviceId: "strap")) + store.removeMetadata(active.id) + XCTAssertEqual(store.sessions.map(\.id), [active.id]) + } + + func testLastExportTimePersists() throws { + let directory = try temporaryDirectory() + let store = RawDataSessionStore(directory: directory) + let session = try XCTUnwrap(store.start(deviceId: "strap")) + store.stop() + store.markExported(session.id, now: Date(timeIntervalSince1970: 123)) + + let restored = try XCTUnwrap(RawDataSessionStore(directory: directory).sessions.first) + XCTAssertTrue(restored.exported) + XCTAssertEqual(restored.lastExportedAtMs, 123_000) + } + + func testCompletedSessionCanBeDeleted() throws { + let directory = try temporaryDirectory() + let store = RawDataSessionStore(directory: directory) + let session = try XCTUnwrap(store.start(deviceId: "strap")) + store.stop() + XCTAssertTrue(store.removeMetadata(session.id)) + XCTAssertTrue(store.sessions.isEmpty) + XCTAssertTrue(RawDataSessionStore(directory: directory).sessions.isEmpty) + } + + func testFailedSourceDeletionKeepsSessionRetryable() throws { + let directory = try temporaryDirectory() + let store = RawDataSessionStore(directory: directory) + let session = try XCTUnwrap(store.start(deviceId: "strap")) + store.stop() + + XCTAssertFalse(store.removeMetadata(session.id) { _ in + throw CocoaError(.fileWriteNoPermission) + }) + XCTAssertEqual(store.sessions.map(\.id), [session.id]) + XCTAssertEqual(RawDataSessionStore(directory: directory).sessions.map(\.id), [session.id]) + XCTAssertTrue(store.removeMetadata(session.id)) + } +} diff --git a/StrandTests/RawPhysiologyUnionTests.swift b/StrandTests/RawPhysiologyUnionTests.swift new file mode 100644 index 0000000000..7188981392 --- /dev/null +++ b/StrandTests/RawPhysiologyUnionTests.swift @@ -0,0 +1,123 @@ +import XCTest +import WhoopProtocol +import WhoopStore +import StrandAnalytics +@testable import Strand + +final class RawPhysiologyUnionTests: XCTestCase { + func testRRMergePreservesDistinctSameTimestampBeatsAndDedupesExactIdentityActiveFirst() { + let active = [RRInterval(ts: 100, rrMs: 800, ord: 8, seq: 0), + RRInterval(ts: 100, rrMs: 800, ord: 9, seq: 1)] + let canonical = [RRInterval(ts: 100, rrMs: 800, ord: 1, seq: 0), + RRInterval(ts: 101, rrMs: 810, ord: 2, seq: 0)] + let merged = Repository.mergeRRByIdentity([active, canonical]) + XCTAssertEqual(merged.map { "\($0.ts):\($0.rrMs):\($0.seq)" }, + ["100:800:0", "100:800:1", "101:810:0"]) + XCTAssertEqual(merged.first?.ord, 8, "active source wins only the exact duplicate") + } + + func testSingleSourceRRIsReturnedUnchanged() { + let rows = [RRInterval(ts: 101, rrMs: 810, ord: 4, seq: 2), + RRInterval(ts: 100, rrMs: 800, ord: 3, seq: 1)] + XCTAssertEqual(Repository.mergeRRByIdentity([rows]), rows) + } + + func testRRUnionSortsByTimestampThenSequenceBeforeValueAndOrder() { + let rows = Repository.mergeRRByIdentity([ + [RRInterval(ts: 100, rrMs: 700, ord: 0, seq: 1)], + [RRInterval(ts: 100, rrMs: 900, ord: 9, seq: 0), + RRInterval(ts: 100, rrMs: 800, ord: nil, seq: 0)], + ]) + XCTAssertEqual(rows.map { "\($0.seq):\($0.rrMs):\($0.ord.map(String.init) ?? "nil")" }, + ["0:800:nil", "0:900:9", "1:700:0"]) + } + + @MainActor + func testRRFacadeUnionsReAddedStrapAndCanonical() async throws { + let store = try await WhoopStore.inMemory() + let repo = Repository(deviceId: "my-whoop") + repo.setStoreForTesting(store) + _ = try await store.insert(Streams(rr: [RRInterval(ts: 100, rrMs: 800)]), deviceId: "my-whoop") + _ = try await store.insert(Streams(rr: [RRInterval(ts: 101, rrMs: 810)]), deviceId: "whoop-new") + repo.adoptActiveDeviceId("whoop-new") + let rows = await repo.rrIntervals(from: 90, to: 110) + XCTAssertEqual(rows.map(\.ts), [100, 101]) + } + + @MainActor + func testRawFacadeIncludesAnotherRegisteredWhoopNotCurrentlyActive() async throws { + let store = try await WhoopStore.inMemory() + let registry = DeviceRegistryStore(dbQueue: store.registryWriter) + try registry.add(PairedDevice(id: "whoop-a", brand: "WHOOP", model: "5.0", + sourceKind: .liveBLE, capabilities: [.hr], status: .archived, addedAt: 1, lastSeenAt: 1)) + try registry.add(PairedDevice(id: "whoop-b", brand: "WHOOP", model: "5.0", + sourceKind: .liveBLE, capabilities: [.hr], status: .active, addedAt: 2, lastSeenAt: 2)) + _ = try await store.insert(Streams(hr: [HRSample(ts: 100, bpm: 61)]), deviceId: "whoop-a") + _ = try await store.insert(Streams(hr: [HRSample(ts: 200, bpm: 72)]), deviceId: "whoop-b") + let repo = Repository(deviceId: "whoop-b") + repo.setStoreForTesting(store) + let rows = await repo.hrSamples(from: 90, to: 210) + XCTAssertEqual(rows.map(\.bpm), [61, 72]) + + let timeline = await repo.timelineSeries(metric: .hr, from: 90, to: 210, targetPoints: 600) + XCTAssertEqual(timeline.points.map { Int($0.value) }, [61, 72], + "default Deep Timeline must use the same all-WHOOP raw resolver") + } + + @MainActor + func testSessionMotionUsesHistoricalOwnerBeforeCurrentActiveComputedSource() async throws { + let store = try await WhoopStore.inMemory() + let session = CachedSleepSession(startTs: 1_000, endTs: 5_000, efficiency: 0.9, + restingHr: 52, avgHrv: 60, stagesJSON: nil) + _ = try await store.upsertSleepSessions([session], deviceId: "my-whoop") + _ = try await store.upsertSleepSessions([session], deviceId: "my-whoop-noop") + _ = try await store.persistSessionMotion(deviceId: "my-whoop-noop", sessionStart: 1_000, + motionEpochs: [0.1, 0.2]) + _ = try await store.upsertSleepSessions([session], deviceId: "whoop-new-noop") + _ = try await store.persistSessionMotion(deviceId: "whoop-new-noop", sessionStart: 1_000, + motionEpochs: [9.0]) + let repo = Repository(deviceId: "whoop-new") + repo.setStoreForTesting(store) + let motion = await repo.sessionMotions(sessions: [session]) + XCTAssertEqual(motion[1_000] ?? [], [0.1, 0.2]) + } + + @MainActor + func testArchivedStrapNightsTeachHabitualMidsleep() async throws { + let store = try await WhoopStore.inMemory() + let registry = DeviceRegistryStore(dbQueue: store.registryWriter) + try registry.add(PairedDevice(id: "whoop-old", brand: "WHOOP", model: "4.0", + sourceKind: .liveBLE, capabilities: [.sleep], status: .archived, addedAt: 1, lastSeenAt: 1)) + try registry.add(PairedDevice(id: "whoop-new", brand: "WHOOP", model: "5.0", + sourceKind: .liveBLE, capabilities: [.sleep], status: .active, addedAt: 2, lastSeenAt: 2)) + let firstStart = Int(Date().timeIntervalSince1970) - 20 * 86_400 + let nights = (0.. BodyVitalReading { + BodyVitalReading( + key: "skin", label: "Skin Temp", unit: "°C", value: 34.6, + format: { String(format: "%.1f", $0) }, + banding: VitalBands.Result(band: .inRange, basis: .population, nights: 24), + metricColor: .orange, day: "2026-08-25", source: .noopComputed, + missingCaption: "none", caveat: caveat, secondary: secondary) + } + + func testTheSecondaryLeadsTheCaptionSoItSitsUnderTheValue() { + let caption = reading(secondary: "+0.9 Δ°F").stateCaption + XCTAssertTrue(caption.hasPrefix("+0.9 Δ°F · "), + "the deviation must come first, directly under the headline — got \(caption)") + XCTAssertTrue(caption.contains("25 Aug") || caption.contains("Aug 25"), + "the day must still be there — got \(caption)") + } + + func testWithoutASecondaryTheCaptionIsUnchanged() { + // Every other vital passes nil, so their captions must be byte-identical to before. + let caption = reading(secondary: nil).stateCaption + XCTAssertFalse(caption.hasPrefix(" · "), "a nil secondary must not leave an empty leading part") + XCTAssertFalse(caption.contains("Δ")) + } + + func testTheSecondaryIsIndependentOfTheCaveat() { + // `caveat` says the reading is unreliable; `secondary` says what it means. A tile may carry + // both, and they must not be confused for one another. + let caption = reading(secondary: "+0.9 Δ°F", caveat: "unverified").stateCaption + XCTAssertTrue(caption.hasPrefix("+0.9 Δ°F · ")) + XCTAssertTrue(caption.hasSuffix("unverified")) + } + + // MARK: - The tile itself, not just the caption helper + + /// A day key `daysAgo` before the CURRENT logical day. + /// + /// Not a hardcoded date: `latest()` resolves through `Baselines.freshestCarried`, which only carries + /// a reading within `vitalCarryDays` of today. A fixed fixture date passes now and silently stops + /// resolving once the real clock moves past that window — a test that rots into a false green. + private func dayKey(_ daysAgo: Int) -> String { + Baselines.cutoffKey(todayKey: BodyVitalSigns.logicalDayKey(Date()), carryDays: daysAgo) + } + + private func day(_ d: String, abs: Double? = nil, dev: Double? = nil) -> DailyMetric { + DailyMetric(day: d, totalSleepMin: nil, efficiency: nil, deepMin: nil, remMin: nil, + lightMin: nil, disturbances: nil, restingHr: nil, avgHrv: nil, + recovery: nil, strain: nil, exerciseCount: nil, + skinTempDevC: dev, skinTempC: abs) + } + + private func skinTile(_ days: [DailyMetric], + _ unit: TemperatureUnit = .celsius) throws -> BodyVitalReading { + let all = BodyVitalSigns.readings(days: days, today: days.last, temperatureUnit: unit) + return try XCTUnwrap(all.first { $0.key == "skin" }) + } + + /// The reported case: the night measured both, so the absolute leads and the deviation sits under it. + func testANightWithBothLeadsWithTheAbsolute() throws { + let tile = try skinTile([day(dayKey(0), abs: 34.6, dev: 0.52)]) + XCTAssertEqual(try XCTUnwrap(tile.value), 34.6, accuracy: 0.001) + XCTAssertEqual(tile.unit, "°C") + XCTAssertEqual(tile.secondary, "+0.5 Δ°C") + } + + func testTheAbsoluteAndItsNoteFollowTheTemperatureSetting() throws { + let tile = try skinTile([day(dayKey(0), abs: 34.6, dev: 0.52)], .fahrenheit) + XCTAssertEqual(tile.unit, "°F") + XCTAssertEqual(tile.secondary, "+0.9 Δ°F") + } + + /// A night predating the column keeps exactly the tile that shipped before. + func testADeviationOnlyNightIsUnchanged() throws { + let tile = try skinTile([day(dayKey(0), dev: 0.52)]) + XCTAssertEqual(try XCTUnwrap(tile.value), 0.52, accuracy: 0.001) + XCTAssertEqual(tile.unit, "Δ°C") + XCTAssertNil(tile.secondary, "a deviation-led tile would only repeat its own headline") + } + + /// Calibrating: the absolute is measured before the baseline is usable, so there is no deviation + /// yet. The tile shows the temperature instead of "needs ~4 worn nights", and omits the note. + func testACalibratingNightShowsTheAbsoluteWithNoNote() throws { + let tile = try skinTile([day(dayKey(0), abs: 34.6)]) + XCTAssertEqual(try XCTUnwrap(tile.value), 34.6, accuracy: 0.001) + XCTAssertEqual(tile.unit, "°C") + XCTAssertNil(tile.secondary) + } + + /// The regression the displayed-row rule exists for: an import-only night is NEWER than the last + /// strap night. The tile must stay on the import rather than stepping back to a stale absolute. + func testANewerImportOnlyNightDoesNotStepBackToAnOlderAbsolute() throws { + let tile = try skinTile([ + day(dayKey(1), abs: 34.6, dev: 0.52), // the strap's last scored night + day(dayKey(0), dev: 0.20), // a WHOOP CSV import: deviation only + ]) + XCTAssertEqual(try XCTUnwrap(tile.value), 0.20, accuracy: 0.001, + "showing 34.6 here would be yesterday's reading dressed as today's") + XCTAssertEqual(tile.day, dayKey(0)) + XCTAssertNil(tile.secondary) + } + + /// The secondary must be THIS night's deviation. Reaching for the freshest one anywhere would + /// print a previous night's number under tonight's temperature. + func testTheNoteBelongsToTheDisplayedNight() throws { + let tile = try skinTile([ + day(dayKey(1), abs: 33.9, dev: -0.40), + day(dayKey(0), abs: 34.6, dev: 0.52), + ]) + XCTAssertEqual(tile.secondary, "+0.5 Δ°C", "the note must not come from the previous night") + } + + /// The formatting the tile's secondary carries, pinned to the same helper Android formats with. + func testTheDeviationNoteMatchesTheKotlinTwin() { + func note(_ c: Double, fahrenheit: Bool) -> String { + let n = SkinTempDisplay.numberString(c, kind: .deviation, fahrenheit: fahrenheit, decimals: 1) + return "\(n) \(SkinTempDisplay.unitSymbol(kind: .deviation, fahrenheit: fahrenheit))" + } + XCTAssertEqual(note(0.52, fahrenheit: false), "+0.5 Δ°C") + XCTAssertEqual(note(0.52, fahrenheit: true), "+0.9 Δ°F") + XCTAssertEqual(note(-0.5, fahrenheit: false), "-0.5 Δ°C") + XCTAssertEqual(note(-0.5, fahrenheit: true), "-0.9 Δ°F") + // A whole degree of DEVIATION is 1.8 °F, never 33.8 — no +32 offset on a difference. + XCTAssertEqual(note(1.0, fahrenheit: true), "+1.8 Δ°F") + } +} diff --git a/StrandTests/SkinTempBackfillTests.swift b/StrandTests/SkinTempBackfillTests.swift new file mode 100644 index 0000000000..09ed34925c --- /dev/null +++ b/StrandTests/SkinTempBackfillTests.swift @@ -0,0 +1,229 @@ +import XCTest +import StrandAnalytics +import WhoopProtocol + +/// #1853: tests for the PURE half of the skin-temp absolute backfill — the session-attribution rule +/// (rule 1), the anchor rule (rule 3), and the pagination rule (rule 2). The I/O walker that reads +/// from / writes to `WhoopStore` is covered separately. +final class SkinTempBackfillTests: XCTestCase { + + // MARK: Rule 1 — attribute sessions by END + + /// A session whose END falls in the day is matched; one whose END falls in the previous day is not. + /// The night window is 54 h wide and contains the previous night, so filtering by end is the only + /// thing that keeps two nights from being averaged into one temperature. + func testSessionsForDay_matchesByEndNotStart() { + // dayStart = 2026-08-25 00:00 UTC = 1_789_632_000 + let dayStart = 1_789_632_000 + let prevNightEnd = dayStart - 3_600 // 23:00 the PREVIOUS day → NOT matched + let thisNightEnd = dayStart + 7 * 3_600 // 07:00 this day → matched + let nextNightEnd = dayStart + 86_400 + 3_600 // 01:00 NEXT day → NOT matched + let sessions = [ + SleepSession(start: dayStart - 8 * 3_600, end: prevNightEnd, efficiency: 0.9, + stages: [], restingHR: nil, avgHRV: nil, hrOnly: false), + SleepSession(start: dayStart - 1 * 3_600, end: thisNightEnd, efficiency: 0.9, + stages: [], restingHR: nil, avgHRV: nil, hrOnly: false), + SleepSession(start: dayStart + 20 * 3_600, end: nextNightEnd, efficiency: 0.9, + stages: [], restingHR: nil, avgHRV: nil, hrOnly: false), + ] + let matched = SkinTempBackfill.sessionsForDay(sessions, dayStart: dayStart) + XCTAssertEqual(matched.count, 1) + XCTAssertEqual(matched.first?.end, thisNightEnd) + } + + /// A session ending exactly at midnight (dayStart) is matched (>= dayStart). + func testSessionsForDay_inclusiveAtDayStart() { + let dayStart = 1_789_632_000 + let sessions = [ + SleepSession(start: dayStart - 8 * 3_600, end: dayStart, efficiency: 0.9, + stages: [], restingHR: nil, avgHRV: nil, hrOnly: false), + ] + XCTAssertEqual(SkinTempBackfill.sessionsForDay(sessions, dayStart: dayStart).count, 1) + } + + /// A session ending exactly at next midnight (dayStart + 86400) is NOT matched (< dayEnd). + func testSessionsForDay_exclusiveAtDayEnd() { + let dayStart = 1_789_632_000 + let sessions = [ + SleepSession(start: dayStart + 12 * 3_600, end: dayStart + 86_400, efficiency: 0.9, + stages: [], restingHR: nil, avgHRV: nil, hrOnly: false), + ] + XCTAssertEqual(SkinTempBackfill.sessionsForDay(sessions, dayStart: dayStart).count, 0) + } + + /// Empty sessions → empty match (the night declines with "no sleep session ends on this day"). + func testSessionsForDay_emptySessionsReturnsEmpty() { + XCTAssertEqual(SkinTempBackfill.sessionsForDay([], dayStart: 0).count, 0) + } + + // MARK: Rule 3 — the WHOOP 4.0 anchor comes from the current window + + /// A WHOOP 4.0 with a per-device anchor from the current window uses it. + func testResolveAnchor_whoop4WithAnchorReturnsIt() { + let anchor = SkinTempBackfill.resolveAnchor(family: .whoop4, windowAnchorRaw: 1290.0) + XCTAssertEqual(anchor, 1290.0) + } + + /// A WHOOP 4.0 with NO per-device anchor returns nil → the night DECLINES. The global 826 is NOT + /// a fallback here — a re-learned anchor puts backfilled nights on a different offset from the + /// stored ones and the chart plots two scales as one line. + func testResolveAnchor_whoop4WithoutAnchorDeclines() { + let anchor = SkinTempBackfill.resolveAnchor(family: .whoop4, windowAnchorRaw: nil) + XCTAssertNil(anchor, "rule 3: no anchor ⇒ decline, never the global default") + } + + /// A WHOOP 5/MG always returns nil anchor (centidegree path, no anchor) — and does NOT decline on + /// anchor grounds (the decline check in `computeNight` is `family == .whoop4 && anchor == nil`). + func testResolveAnchor_whoop5ReturnsNilButDoesNotDecline() { + let anchor = SkinTempBackfill.resolveAnchor(family: .whoop5, windowAnchorRaw: nil) + XCTAssertNil(anchor) + // The computeNight test below confirms a 5/MG night proceeds without an anchor. + } + + // MARK: computeNight — the full per-night plan + + /// A 5/MG night with enough worn samples fills. The centidegree path needs no anchor. + func testComputeNight_whoop5WithEnoughSamplesFills() { + let dayStart = 1_789_632_000 + let candidate = SkinTempBackfill.NightCandidate( + day: "2026-08-25", from: dayStart - 30 * 3_600, to: dayStart + 24 * 3_600, owner: "my-whoop") + // A session ending in this day. + let sessions = [ + SleepSession(start: dayStart - 1 * 3_600, end: dayStart + 7 * 3_600, efficiency: 0.9, + stages: [], restingHR: nil, avgHRV: nil, hrOnly: false), + ] + // 400 worn skin-temp samples (3057 raw = 30.57 °C on 5/MG) at 1 Hz, with matching HR. + let skinTemp = (0..<400).map { i in + SkinTempSample(ts: dayStart - 1 * 3_600 + i, raw: 3057) + } + let hr = (0..<400).map { i in HRSample(ts: dayStart - 1 * 3_600 + i, bpm: 60) } + let result = SkinTempBackfill.computeNight( + candidate: candidate, sessions: sessions, hr: hr, skinTemp: skinTemp, + family: .whoop5, windowAnchorRaw: nil, dayStart: dayStart) + XCTAssertNotNil(result.skinTempC) + XCTAssertEqual(result.skinTempC!, 30.57, accuracy: 0.01) + XCTAssertNil(result.declineReason) + } + + /// A WHOOP 4.0 night with no per-device anchor DECLINES (rule 3). Even with enough samples the + /// night is not filled — the global 826 is NOT a fallback. + func testComputeNight_whoop4WithoutAnchorDeclines() { + let dayStart = 1_789_632_000 + let candidate = SkinTempBackfill.NightCandidate( + day: "2026-08-25", from: dayStart - 30 * 3_600, to: dayStart + 24 * 3_600, owner: "my-whoop") + let sessions = [ + SleepSession(start: dayStart - 1 * 3_600, end: dayStart + 7 * 3_600, efficiency: 0.9, + stages: [], restingHR: nil, avgHRV: nil, hrOnly: false), + ] + // Enough samples to fill IF the anchor were allowed — but rule 3 declines before the funnel. + let skinTemp = (0..<400).map { i in + SkinTempSample(ts: dayStart - 1 * 3_600 + i, raw: 826) + } + let hr = (0..<400).map { i in HRSample(ts: dayStart - 1 * 3_600 + i, bpm: 60) } + let result = SkinTempBackfill.computeNight( + candidate: candidate, sessions: sessions, hr: hr, skinTemp: skinTemp, + family: .whoop4, windowAnchorRaw: nil, dayStart: dayStart) + XCTAssertNil(result.skinTempC, "rule 3: no anchor ⇒ decline, never the global default") + XCTAssertNotNil(result.declineReason) + XCTAssertTrue(result.declineReason!.contains("anchor"), "decline reason names the anchor") + } + + /// A night with no sessions ending on the day declines. + func testComputeNight_noSessionsDeclines() { + let dayStart = 1_789_632_000 + let candidate = SkinTempBackfill.NightCandidate( + day: "2026-08-25", from: dayStart - 30 * 3_600, to: dayStart + 24 * 3_600, owner: "my-whoop") + let result = SkinTempBackfill.computeNight( + candidate: candidate, sessions: [], hr: [], skinTemp: [], + family: .whoop5, windowAnchorRaw: nil, dayStart: dayStart) + XCTAssertNil(result.skinTempC) + XCTAssertEqual(result.declineReason, "no sleep session ends on this day") + } + + /// A night with too few worn samples (< minSkinTempSamples = 300) declines. + func testComputeNight_tooFewSamplesDeclines() { + let dayStart = 1_789_632_000 + let candidate = SkinTempBackfill.NightCandidate( + day: "2026-08-25", from: dayStart - 30 * 3_600, to: dayStart + 24 * 3_600, owner: "my-whoop") + let sessions = [ + SleepSession(start: dayStart - 1 * 3_600, end: dayStart + 7 * 3_600, efficiency: 0.9, + stages: [], restingHR: nil, avgHRV: nil, hrOnly: false), + ] + // Only 100 samples — below the 300 floor. + let skinTemp = (0..<100).map { i in + SkinTempSample(ts: dayStart - 1 * 3_600 + i, raw: 3057) + } + let hr = (0..<100).map { i in HRSample(ts: dayStart - 1 * 3_600 + i, bpm: 60) } + let result = SkinTempBackfill.computeNight( + candidate: candidate, sessions: sessions, hr: hr, skinTemp: skinTemp, + family: .whoop5, windowAnchorRaw: nil, dayStart: dayStart) + XCTAssertNil(result.skinTempC) + XCTAssertNotNil(result.declineReason) + XCTAssertTrue(result.declineReason!.contains("worn samples"), "decline reason names the sample floor") + } + + /// A night whose sessions END in the PREVIOUS day is not matched (rule 1), even if the session + /// span overlaps this day's window. This is the exact bug rule 1 prevents: two nights averaged + /// into one temperature. + func testComputeNight_previousNightSessionNotMatched() { + let dayStart = 1_789_632_000 + let candidate = SkinTempBackfill.NightCandidate( + day: "2026-08-25", from: dayStart - 30 * 3_600, to: dayStart + 24 * 3_600, owner: "my-whoop") + // A session that ends in the PREVIOUS day (23:00 on Aug 24) but whose span overlaps this day's + // 54 h window. Without rule 1 this would be matched and the night would fill. + let sessions = [ + SleepSession(start: dayStart - 9 * 3_600, end: dayStart - 1 * 3_600, efficiency: 0.9, + stages: [], restingHR: nil, avgHRV: nil, hrOnly: false), + ] + let skinTemp = (0..<400).map { i in + SkinTempSample(ts: dayStart - 9 * 3_600 + i, raw: 3057) + } + let hr = (0..<400).map { i in HRSample(ts: dayStart - 9 * 3_600 + i, bpm: 60) } + let result = SkinTempBackfill.computeNight( + candidate: candidate, sessions: sessions, hr: hr, skinTemp: skinTemp, + family: .whoop5, windowAnchorRaw: nil, dayStart: dayStart) + XCTAssertNil(result.skinTempC, "rule 1: a session ending in the previous day is not this day's") + XCTAssertEqual(result.declineReason, "no sleep session ends on this day") + } + + // MARK: Rule 2 — page the candidates + + /// Page 0 returns the first `pageSize` candidates, oldest-first. + func testPage_firstPageReturnsOldestN() { + let candidates = (0..<120).map { i in + SkinTempBackfill.NightCandidate(day: "2026-08-\(String(format: "%02d", i + 1))", + from: 0, to: 0, owner: "my-whoop") + } + let page0 = SkinTempBackfill.page(candidates, page: 0, pageSize: 50) + XCTAssertEqual(page0.count, 50) + XCTAssertEqual(page0.first?.day, "2026-08-01") + XCTAssertEqual(page0.last?.day, "2026-08-50") + } + + /// Page 2 returns the next chunk; page 3 is empty (only 120 candidates, 50 per page → 3 pages). + func testPage_thirdPageEmpty() { + let candidates = (0..<120).map { i in + SkinTempBackfill.NightCandidate(day: "2026-08-\(String(format: "%02d", i + 1))", + from: 0, to: 0, owner: "my-whoop") + } + let page2 = SkinTempBackfill.page(candidates, page: 2, pageSize: 50) + XCTAssertEqual(page2.count, 20) + let page3 = SkinTempBackfill.page(candidates, page: 3, pageSize: 50) + XCTAssertTrue(page3.isEmpty, "page 3 is empty — the walker stops here") + } + + /// An empty candidate list returns an empty page on every page. + func testPage_emptyCandidatesReturnsEmpty() { + XCTAssertTrue(SkinTempBackfill.page([], page: 0).isEmpty) + } + + /// A partial last page returns exactly the remainder. + func testPage_partialLastPage() { + let candidates = (0..<75).map { i in + SkinTempBackfill.NightCandidate(day: "2026-08-\(String(format: "%02d", i + 1))", + from: 0, to: 0, owner: "my-whoop") + } + let page1 = SkinTempBackfill.page(candidates, page: 1, pageSize: 50) + XCTAssertEqual(page1.count, 25) + } +} diff --git a/StrandTests/SkinTempExplorerNoteTests.swift b/StrandTests/SkinTempExplorerNoteTests.swift new file mode 100644 index 0000000000..25778f625c --- /dev/null +++ b/StrandTests/SkinTempExplorerNoteTests.swift @@ -0,0 +1,66 @@ +import XCTest +import StrandAnalytics +@testable import Strand + +/// #1848: the skin-temp explorer's two explanatory notes (#1847), pure-function twins of Android's +/// `shouldExplainSkinTempFallback` and `shouldExplainShortenedSkinTempSeries`. +/// +/// Both notes are gated so they fire ONLY when the sentence they carry is true — a note that says the +/// opposite of what happened is worse than silence. The fallback note fires when the user asked for +/// temperatures and the window has none; the shortened-series note fires when leading with absolutes +/// dropped deviation-only nights. The deviation-led branch also drops rows (calibrating nights with +/// only an absolute), but those are the OPPOSITE kind, so the shortened-series note is gated to +/// absolute-led only. +final class SkinTempExplorerNoteTests: XCTestCase { + + // MARK: - shouldExplainSkinTempFallback + + func testFallbackExplainedWhenUserAskedForAbsoluteButWindowHasNone() { + XCTAssertTrue(shouldExplainSkinTempFallback( + prefer: .absolute, leadsAbsolute: false, anyAbsoluteInWindow: false)) + } + + func testFallbackSilentWhenWindowHasAbsoluteEvenIfLatestLacksOne() { + // #1850: the preference applies across the window. A window with one stored temperature + // and twenty deltas still leads with temperatures, so the fallback note stays silent. + XCTAssertFalse(shouldExplainSkinTempFallback( + prefer: .absolute, leadsAbsolute: true, anyAbsoluteInWindow: true)) + } + + func testFallbackSilentWhenUserAskedForDeviation() { + // The user chose "vs baseline" — showing deviations is honouring the choice, not falling back. + XCTAssertFalse(shouldExplainSkinTempFallback( + prefer: .deviation, leadsAbsolute: false, anyAbsoluteInWindow: false)) + } + + func testFallbackSilentWhenLeadingAbsoluteAsRequested() { + XCTAssertFalse(shouldExplainSkinTempFallback( + prefer: .absolute, leadsAbsolute: true, anyAbsoluteInWindow: true)) + } + + // MARK: - shouldExplainShortenedSkinTempSeries + + func testShortenedSeriesExplainedWhenAbsoluteLedDropsNights() { + XCTAssertTrue(shouldExplainShortenedSkinTempSeries( + leadsAbsolute: true, shownReadings: 21, rowsWithEitherNumber: 40)) + } + + func testShortenedSeriesSilentWhenEveryNightIsShown() { + XCTAssertFalse(shouldExplainShortenedSkinTempSeries( + leadsAbsolute: true, shownReadings: 23, rowsWithEitherNumber: 23)) + } + + func testShortenedSeriesSilentWhenNothingToShow() { + XCTAssertFalse(shouldExplainShortenedSkinTempSeries( + leadsAbsolute: true, shownReadings: 0, rowsWithEitherNumber: 0)) + } + + func testShortenedSeriesNeverExplainsWhenLeadingWithDeviation() { + // The deviation-led branch ALSO drops rows (calibrating nights with only an absolute), but + // those are the OPPOSITE kind — "only nights with a measured temperature are shown" would be + // precisely backwards. Ungated, this fired the moment a wearer picked "vs baseline" with any + // calibrating night in the window. + XCTAssertFalse(shouldExplainShortenedSkinTempSeries( + leadsAbsolute: false, shownReadings: 21, rowsWithEitherNumber: 40)) + } +} diff --git a/StrandTests/SleepCarriedStampTests.swift b/StrandTests/SleepCarriedStampTests.swift new file mode 100644 index 0000000000..1208dbfe52 --- /dev/null +++ b/StrandTests/SleepCarriedStampTests.swift @@ -0,0 +1,73 @@ +import XCTest +import WhoopStore +@testable import Strand + +/// #1946: a carried prior-night metric value must be stamped with its day so it is not passed off +/// as tonight's read. The `SleepModel.Metric` tuple now carries `latestDay` alongside `latest`, and +/// `carriedMetricCaption` returns "Carried · " when the value is from a prior day. Byte-parity +/// twin of Kotlin `SleepCarriedStampTest`. +final class SleepCarriedStampTests: XCTestCase { + + private func day(_ d: String, resp: Double? = nil, eff: Double? = nil) -> DailyMetric { + DailyMetric(day: d, totalSleepMin: 420, efficiency: eff, + deepMin: 80, remMin: 90, lightMin: 200, disturbances: nil, + restingHr: nil, avgHrv: nil, recovery: nil, strain: nil, + exerciseCount: nil, spo2Pct: nil, skinTempDevC: nil, respRateBpm: resp) + } + + private func noon(_ d: String) -> Date { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = "yyyy-MM-dd HH:mm" + f.timeZone = .current + return f.date(from: "\(d) 12:00")! + } + + /// A value from TODAY's own day is NOT carried — `latestDay` is nil, no stamp. + func testTodayValueIsNotCarried() { + let days = [day("2026-08-13", resp: 15.6)] + let resp = SleepModel.metric(days: days, now: noon("2026-08-13")) { $0.respRateBpm } + XCTAssertEqual(resp.latest ?? 0, 15.6, accuracy: 1e-9) + XCTAssertNil(resp.latestDay, "today's own value must not be marked as carried") + XCTAssertNil(SleepModel.carriedMetricCaption(latestDay: resp.latestDay, latest: resp.latest)) + } + + /// A value from a PRIOR day within the carry window IS carried — `latestDay` is set, and the + /// caption stamps it. + func testPriorDayValueIsCarriedAndStamped() { + let days = [day("2026-08-11", resp: 14.1), day("2026-08-12")] + let resp = SleepModel.metric(days: days, now: noon("2026-08-13")) { $0.respRateBpm } + XCTAssertEqual(resp.latest ?? 0, 14.1, accuracy: 1e-9) + XCTAssertEqual(resp.latestDay, "2026-08-11", "the carried value's source day is tracked") + let caption = SleepModel.carriedMetricCaption(latestDay: resp.latestDay, latest: resp.latest) + XCTAssertNotNil(caption, "a carried value must produce a stamp caption") + XCTAssertTrue(caption!.contains("Carried"), caption!) + XCTAssertTrue(caption!.contains("11"), caption!) + } + + /// A nil latest has no stamp — the tile falls through to "vs typical" or "—". + func testNoValueHasNoStamp() { + XCTAssertNil(SleepModel.carriedMetricCaption(latestDay: "2026-08-11", latest: nil)) + XCTAssertNil(SleepModel.carriedMetricCaption(latestDay: nil, latest: 15.6)) + XCTAssertNil(SleepModel.carriedMetricCaption(latestDay: nil, latest: nil)) + } + + /// A stale value (outside the carry window) has no latest and no stamp — it was already nilled + /// by `freshestCarried`. + func testStaleValueHasNoLatestAndNoStamp() { + var days = [day("2026-07-29", resp: 16.2), day("2026-07-30", resp: 15.6)] + days += (1...13).map { day(String(format: "2026-08-%02d", $0)) } + let resp = SleepModel.metric(days: days, now: noon("2026-08-13")) { $0.respRateBpm } + XCTAssertNil(resp.latest) + XCTAssertNil(resp.latestDay) + XCTAssertNil(SleepModel.carriedMetricCaption(latestDay: resp.latestDay, latest: resp.latest)) + } + + /// A fresh sibling metric (efficiency, present every night) is NOT carried when today has a value. + func testFreshSiblingIsNotCarried() { + let days = [day("2026-08-12", eff: 90), day("2026-08-13", eff: 88)] + let eff = SleepModel.metric(days: days, now: noon("2026-08-13")) { $0.efficiency } + XCTAssertEqual(eff.latest ?? 0, 88, accuracy: 1e-9) + XCTAssertNil(eff.latestDay, "today's own efficiency is not carried") + } +} diff --git a/StrandTests/SleepLayoutPrefsTests.swift b/StrandTests/SleepLayoutPrefsTests.swift index c492273544..475f574024 100644 --- a/StrandTests/SleepLayoutPrefsTests.swift +++ b/StrandTests/SleepLayoutPrefsTests.swift @@ -18,7 +18,9 @@ final class SleepLayoutPrefsTests: XCTestCase { ] let encoded = SleepLayoutPrefs.encode(reordered) XCTAssertEqual(encoded, "nightDetail,sleepMarks,asleepDuration,stages,sleepDebt,stagesVsTypical") - XCTAssertEqual(SleepLayoutPrefs.decodeOrder(encoded), reordered) + // `bodyClock` is absent from the saved order, so decode inserts it at its default position — the + // migration path every newly-added section takes for an existing customised layout. + XCTAssertEqual(SleepLayoutPrefs.decodeOrder(encoded), [.bodyClock] + reordered) } /// A saved order that leads with `asleepDuration` and ends on `sleepMarks` keeps those two placements @@ -28,7 +30,7 @@ final class SleepLayoutPrefsTests: XCTestCase { let decoded = SleepLayoutPrefs.decodeOrder("asleepDuration,sleepMarks") XCTAssertEqual(decoded.count, SleepSection.allCases.count) XCTAssertEqual(decoded, [ - .stages, .nightDetail, .sleepDebt, .stagesVsTypical, .asleepDuration, .sleepMarks, + .stages, .bodyClock, .nightDetail, .sleepDebt, .stagesVsTypical, .asleepDuration, .sleepMarks, ]) } @@ -56,7 +58,7 @@ final class SleepLayoutPrefsTests: XCTestCase { let order = "nightDetail,sleepMarks,asleepDuration,stages,sleepDebt,stagesVsTypical" XCTAssertEqual( SleepLayoutPrefs.visibleOrder(orderRaw: order, hiddenRaw: "asleepDuration,sleepDebt"), - [.nightDetail, .sleepMarks, .stages, .stagesVsTypical] + [.bodyClock, .nightDetail, .sleepMarks, .stages, .stagesVsTypical] ) XCTAssertEqual(SleepLayoutPrefs.decodeOrder(order).count, SleepSection.allCases.count) } @@ -79,7 +81,8 @@ final class SleepLayoutPrefsTests: XCTestCase { XCTAssertEqual(raws.count, Set(raws).count) // Pin the exact wire strings — they cross the .noopbak boundary and must match Android byte-for-byte. XCTAssertEqual(raws, [ - "sleepMarks", "stages", "nightDetail", "sleepDebt", "stagesVsTypical", "asleepDuration", + "sleepMarks", "stages", "bodyClock", "nightDetail", "sleepDebt", "stagesVsTypical", + "asleepDuration", ]) } } diff --git a/StrandTests/SleepModelDebtTests.swift b/StrandTests/SleepModelDebtTests.swift new file mode 100644 index 0000000000..ba33402ae4 --- /dev/null +++ b/StrandTests/SleepModelDebtTests.swift @@ -0,0 +1,29 @@ +import XCTest +import WhoopStore +@testable import Strand + +final class SleepModelDebtTests: XCTestCase { + private func day(_ value: String, sleep: Double?) -> DailyMetric { + DailyMetric(day: value, totalSleepMin: sleep, efficiency: nil, + deepMin: nil, remMin: nil, lightMin: nil, disturbances: nil, + restingHr: nil, avgHrv: nil, recovery: nil, strain: nil, + exerciseCount: nil, spo2Pct: nil, skinTempDevC: nil, respRateBpm: nil) + } + + func testLocalFallbackLatestAgreesWithLedger() { + let days = [day("2026-06-01", sleep: 360), day("2026-06-02", sleep: 480)] + let metric = SleepModel.sleepDebtSeries( + days: days, importedSleep: [:], napSleepMinByDay: [:]) + let ledger = SleepModel.debtLedger(days: days, napSleepMinByDay: [:]) + XCTAssertEqual(metric.latest ?? -1, ledger.magnitudeMin, accuracy: 1e-9) + } + + func testImportedWhoopDebtRemainsVerbatim() { + let days = [day("2026-06-01", sleep: 480)] + let imported = ["2026-06-01": ImportedSleepFigures( + performancePct: nil, consistencyPct: nil, needMin: nil, debtMin: 17.25)] + let metric = SleepModel.sleepDebtSeries( + days: days, importedSleep: imported, napSleepMinByDay: [:]) + XCTAssertEqual(metric.latest ?? -1, 17.25, accuracy: 1e-9) + } +} diff --git a/StrandTests/SleepPhantomNightFallbackTests.swift b/StrandTests/SleepPhantomNightFallbackTests.swift index 7d0388e1b3..fd0b0a45e5 100644 --- a/StrandTests/SleepPhantomNightFallbackTests.swift +++ b/StrandTests/SleepPhantomNightFallbackTests.swift @@ -77,4 +77,29 @@ final class SleepPhantomNightFallbackTests: XCTestCase { XCTAssertNil(SleepEditGuard.clampedEditWindow(start: phantomWindow.0, end: phantomWindow.1, now: Int(d(2, 5, 3).timeIntervalSince1970))) } + + // Missing-night honesty: the newest stored night may stay visible, but the status above it must say + // whether today's night is still moving through sync/analysis or finished without a detection. + func testFreshnessProgressStatesWinOverStaleHistory() { + XCTAssertEqual(resolveSleepFreshness(hasCurrentNight: false, morningReady: true, syncing: true, + calculating: true, syncedSinceDayStart: false, syncFailed: true), .syncing) + XCTAssertEqual(resolveSleepFreshness(hasCurrentNight: false, morningReady: true, syncing: false, + calculating: true, syncedSinceDayStart: true, syncFailed: true), .calculating) + } + + func testFreshnessDoesNotDeclareMissingBeforeMorningOrForCurrentNight() { + XCTAssertNil(resolveSleepFreshness(hasCurrentNight: false, morningReady: false, syncing: false, + calculating: false, syncedSinceDayStart: true, syncFailed: false)) + XCTAssertNil(resolveSleepFreshness(hasCurrentNight: true, morningReady: true, syncing: false, + calculating: false, syncedSinceDayStart: true, syncFailed: false)) + } + + func testFreshnessDistinguishesFinishedFailedAndWaiting() { + XCTAssertEqual(resolveSleepFreshness(hasCurrentNight: false, morningReady: true, syncing: false, + calculating: false, syncedSinceDayStart: true, syncFailed: false), .notDetected) + XCTAssertEqual(resolveSleepFreshness(hasCurrentNight: false, morningReady: true, syncing: false, + calculating: false, syncedSinceDayStart: false, syncFailed: true), .syncFailed) + XCTAssertEqual(resolveSleepFreshness(hasCurrentNight: false, morningReady: true, syncing: false, + calculating: false, syncedSinceDayStart: false, syncFailed: false), .awaitingSync) + } } diff --git a/StrandTests/SmartAlarmWeekdayTests.swift b/StrandTests/SmartAlarmWeekdayTests.swift index 815aae65b3..a77c489eb3 100644 --- a/StrandTests/SmartAlarmWeekdayTests.swift +++ b/StrandTests/SmartAlarmWeekdayTests.swift @@ -86,6 +86,70 @@ final class SmartAlarmWeekdayTests: XCTestCase { XCTAssertGreaterThan(next!, wed(7, 0)) } + // MARK: nextSmartAlarmDate — per-weekday overrides (#1864) + + /// An override on the scanned day moves the fire time to THAT day's override, not the default. + /// now = Wed 06:00, default 07:00, only Wednesdays selected (4), Wednesday override 03:30 → + /// today 03:30 has passed, so the next Wednesday (7 days later) at 03:30. Without the override + /// this would be today 07:00 (still ahead). + func testOverrideOnToday_afterTime_rollsToNextWeek() { + let next = AppModel.nextSmartAlarmDate(minutes: 7 * 60, weekdays: [4], overrides: [4: 3 * 60 + 30], + from: wed(6, 0), calendar: cal) + XCTAssertEqual(weekday(of: next!), 4, "Wednesday") + XCTAssertEqual(next, cal.date(byAdding: .day, value: 7, to: wed(3, 30))) + } + + /// An override on the scanned day that is LATER than the default can make today's occurrence still + /// pending. now = Wed 06:00, default 07:00, Wednesday override 09:00 → today 09:00 (still ahead). + func testOverrideOnToday_beforeOverrideTime_firesTodayAtOverrideTime() { + let next = AppModel.nextSmartAlarmDate(minutes: 7 * 60, weekdays: [], overrides: [4: 9 * 60], + from: wed(6, 0), calendar: cal) + XCTAssertEqual(next, wed(9, 0)) + XCTAssertEqual(weekday(of: next!), 4) + } + + /// A day WITHOUT an override falls back to the default time. now = Wed 06:00, default 07:00, + /// Tuesday override 03:30 → Wednesday has no override, fires today at 07:00. + func testDayWithoutOverrideUsesDefaultTime() { + let next = AppModel.nextSmartAlarmDate(minutes: 7 * 60, weekdays: [], overrides: [3: 3 * 60 + 30], + from: wed(6, 0), calendar: cal) + XCTAssertEqual(next, wed(7, 0)) + } + + /// An override on a weekday the alarm doesn't fire on is ignored. now = Wed 06:00, default 07:00, + /// only Wednesdays selected (4), Saturday override 03:30 → today 07:00 (Saturday is skipped). + func testOverrideOnDisabledWeekdayIsIgnored() { + let next = AppModel.nextSmartAlarmDate(minutes: 7 * 60, weekdays: [4], overrides: [7: 3 * 60 + 30], + from: wed(6, 0), calendar: cal) + XCTAssertEqual(next, wed(7, 0)) + } + + /// An empty overrides map is byte-for-byte the old path — the default time on every enabled day. + func testEmptyOverridesIsByteForByteOldPath() { + let without = AppModel.nextSmartAlarmDate(minutes: 7 * 60, weekdays: [4], from: wed(6, 0), calendar: cal) + let withEmpty = AppModel.nextSmartAlarmDate(minutes: 7 * 60, weekdays: [4], overrides: [:], + from: wed(6, 0), calendar: cal) + XCTAssertEqual(without, withEmpty) + } + + /// Invalid override entries (day outside 1…7, minute outside [0, 1440)) are dropped, not applied. + func testInvalidOverrideEntriesAreDropped() { + let next = AppModel.nextSmartAlarmDate(minutes: 7 * 60, weekdays: [], overrides: [0: 3 * 60, 4: -1, 99: 9 * 60], + from: wed(6, 0), calendar: cal) + XCTAssertEqual(next, wed(7, 0), "invalid entries dropped, default 07:00 fires today") + } + + /// The reported install: 04:45 on three days and 03:30 on two. The next fire after the default time + /// on a non-override day picks the override time on the next override day that is still ahead. + func testRealWorldMixedOverrides() { + // now = Wed 06:00, default 07:00, Tue=03:30 (3), Fri=04:45 (6) + // Wed has no override → default 07:00 today (still ahead). + let next = AppModel.nextSmartAlarmDate(minutes: 7 * 60, weekdays: [], + overrides: [3: 3 * 60 + 30, 6: 4 * 60 + 45], + from: wed(6, 0), calendar: cal) + XCTAssertEqual(next, wed(7, 0)) + } + // MARK: Picker selection rules func testWeekdayIsSelected_emptyMeansEveryDay() { diff --git a/StrandTests/StandardHRLifecyclePersistenceTests.swift b/StrandTests/StandardHRLifecyclePersistenceTests.swift new file mode 100644 index 0000000000..acb4dd2c89 --- /dev/null +++ b/StrandTests/StandardHRLifecyclePersistenceTests.swift @@ -0,0 +1,55 @@ +import XCTest +import StrandAnalytics +import WhoopProtocol +import WhoopStore +@testable import Strand + +@MainActor +final class StandardHRLifecyclePersistenceTests: XCTestCase { + private final class CountingStore: StoreWriting { + private(set) var offeredHRRows = 0 + private(set) var offeredRRRows = 0 + + func insert(_ streams: Streams, deviceId: String) async throws + -> (hr: Int, rr: Int, events: Int, battery: Int, + spo2: Int, skinTemp: Int, resp: Int, gravity: Int) { + offeredHRRows = streams.hr.count + offeredRRRows = streams.rr.count + // Deliberately differ from the offered counts: this is the store's conflict/dedup result. + return (0, 1, 0, 0, 0, 0, 0, 0) + } + + func enqueueRawBatch(_ meta: RawBatchMeta, frames: [[UInt8]]) async throws {} + } + + func testBackgroundLifecycleFlushesSubThresholdStandardHRAndLogsStoreCounts() async { + let store = CountingStore() + var lines: [String] = [] + let collector = Collector( + store: store, + deviceId: "test-strap", + log: { lines.append($0) }, + now: { 1_750_000_000 } + ) + let manager = BLEManager(state: LiveState(), collector: collector) + + // Three accepted rows are intentionally below the 30-row cadence threshold. The invalid values + // prove that the host-receipt line reports Collector's accepted/rejected split, not input totals. + collector.ingestStandardHR(hr: 72, rr: [800, 100, 900], at: 1_750_000_000) + XCTAssertEqual(store.offeredHRRows + store.offeredRRRows, 0) + + await manager.flushStandardHRForLifecycle(reason: .background) + + XCTAssertEqual(store.offeredHRRows, 1) + XCTAssertEqual(store.offeredRRRows, 2) + XCTAssertTrue(lines.contains( + "standard-hr transport host-received hostUnixSec=1750000000" + + " acceptedHRRows=1 acceptedRRRows=2 rejectedHRRows=0 rejectedRRRows=1" + + " pendingHRRows=1 pendingRRRows=2" + )) + XCTAssertTrue(lines.contains( + "standard-hr transport flush-succeeded reason=background" + + " offeredHRRows=1 offeredRRRows=2 insertedHRRows=0 insertedRRRows=1" + )) + } +} diff --git a/StrandTests/StandardHeartRateTests.swift b/StrandTests/StandardHeartRateTests.swift new file mode 100644 index 0000000000..948fb2f104 --- /dev/null +++ b/StrandTests/StandardHeartRateTests.swift @@ -0,0 +1,31 @@ +import XCTest +import WhoopProtocol +@testable import Strand + +final class StandardHeartRateTests: XCTestCase { + func testContactFlagsCoverAllCombinations() { + let cases: [(flags: UInt8, expected: StandardHRContact)] = [ + (0x00, .unsupported), + (0x02, .unsupported), + (0x04, .supportedNotDetected), + (0x06, .supportedDetected), + ] + + for test in cases { + XCTAssertEqual( + StandardHRContact.fromMeasurementFlags(test.flags), + test.expected, + "flags 0x\(String(test.flags, radix: 16))" + ) + let parsed = StandardHeartRate.parse([test.flags, 72]) + XCTAssertEqual(parsed?.contact, test.expected, "flags 0x\(String(test.flags, radix: 16))") + } + } + + func testContactFlagsDoNotChangeExistingHeartRateOrRRParsing() { + let parsed = StandardHeartRate.parse([0x16, 72, 0x00, 0x04]) + XCTAssertEqual(parsed?.hr, 72) + XCTAssertEqual(parsed?.rr, [1000]) + XCTAssertEqual(parsed?.contact, .supportedDetected) + } +} diff --git a/StrandTests/StrapConsoleMirrorTests.swift b/StrandTests/StrapConsoleMirrorTests.swift new file mode 100644 index 0000000000..8581f690f3 --- /dev/null +++ b/StrandTests/StrapConsoleMirrorTests.swift @@ -0,0 +1,49 @@ +import XCTest +@testable import Strand + +/// Guards that the strap's own console narration actually REACHES the log. +/// +/// The bug this exists for was not a decode bug and not a key bug — both of those were right. Offload +/// frames are routed straight to the Backfiller and never reach `FrameRouter.handle`, and `CONSOLE_LOGS` +/// is an offload type, so the handler that mirrors the text only ever saw the rare console frame arriving +/// outside a sync. Every line worth having — `PullStats`, `History burst success`, `Historical Dump +/// Complete` — is emitted DURING a sync, and was routed past the code meant to surface it. +/// +/// Nothing caught that, because nothing asserted the call site existed. Unit tests passed and app-build +/// was green on a feature that could not fire. So this test reads the source: the two offload branches +/// (WHOOP4 and 5/MG are separate paths) must each invoke the carve-out. +@MainActor +final class StrapConsoleMirrorTests: XCTestCase { + + private func bleManagerSource() throws -> String { + // StrandTests/ → repo root → Strand/BLE/BLEManager.swift + let here = URL(fileURLWithPath: #filePath) + let root = here.deletingLastPathComponent().deletingLastPathComponent() + let url = root.appendingPathComponent("Strand/BLE/BLEManager.swift") + return try String(contentsOf: url, encoding: .utf8) + } + + /// Both offload branches must mirror the console, or one family goes silent mid-sync — which is the + /// exact shape of the original defect, just per-family instead of everywhere. + func testBothOffloadBranchesMirrorTheStrapConsole() throws { + let src = try bleManagerSource() + let calls = src.components(separatedBy: "router.mirrorStrapConsoleIfPresent(frame: frame)").count - 1 + XCTAssertEqual(calls, 2, + "expected the console carve-out in BOTH offload branches (WHOOP4 + 5/MG); " + + "found \(calls). Without it the strap's narration is dropped for that family " + + "during exactly the sync it describes.") + } + + /// The carve-out has to sit INSIDE the offload branch, beside the gesture one. If it drifted out to + /// the live path it would compile, pass the count check above, and still never fire during a sync. + func testTheCarveOutSitsBesideTheLiveGestureOne() throws { + let src = try bleManagerSource() + for part in src.components(separatedBy: "router.mirrorStrapConsoleIfPresent(frame: frame)").dropLast() { + let tail = String(part.suffix(400)) + XCTAssertTrue(tail.contains("dispatchLiveGestureIfFresh"), + "each console carve-out must sit with the live-gesture carve-out inside the " + + "offload branch; one appears to have drifted to the live path, where it is " + + "redundant with `handle` and silent during a sync") + } + } +} diff --git a/StrandTests/StrapProvidesLineTests.swift b/StrandTests/StrapProvidesLineTests.swift new file mode 100644 index 0000000000..c0b4afb9d5 --- /dev/null +++ b/StrandTests/StrapProvidesLineTests.swift @@ -0,0 +1,32 @@ +import XCTest +@testable import Strand + +/// The line that says which scores can exist at all for the strap actually being worn. +/// +/// Twin of the Kotlin `StrapProvidesLineTest`, and the expected strings are the Kotlin ones: these lines +/// exist so an Android and an Apple report compare directly, so asserting each side against itself would +/// prove only that each is self-consistent. +final class StrapProvidesLineTests: XCTestCase { + + func testAnUnbondedMGStreamsHeartDataAndNothingElse() { + XCTAssertEqual( + DebugDataDiagnostics.strapProvidesLine(hr: true, rr: true, motion: false, steps: false), + "Provides: HR yes · R-R yes · motion NO · steps NO (last 48h)" + ) + } + + func testAFullySyncedStrapProvidesAllFour() { + XCTAssertEqual( + DebugDataDiagnostics.strapProvidesLine(hr: true, rr: true, motion: true, steps: true), + "Provides: HR yes · R-R yes · motion yes · steps yes (last 48h)" + ) + } + + /// NO is capitalised and yes is not, deliberately: the absences are what the line exists to surface, + /// and a reader scanning a report should catch them without reading the labels. + func testAbsenceIsTheHalfThatStandsOut() { + let line = DebugDataDiagnostics.strapProvidesLine(hr: true, rr: false, motion: false, steps: true) + XCTAssertEqual(line.components(separatedBy: "NO").count - 1, 2, line) + XCTAssertEqual(line, "Provides: HR yes · R-R NO · motion NO · steps yes (last 48h)") + } +} diff --git a/StrandTests/TestBundleMetaTests.swift b/StrandTests/TestBundleMetaTests.swift index 6521beb758..8dffc8abec 100644 --- a/StrandTests/TestBundleMetaTests.swift +++ b/StrandTests/TestBundleMetaTests.swift @@ -15,7 +15,8 @@ final class TestBundleMetaTests: XCTestCase { profileStartedAt: "2026-06-26T07:12:00Z", questionnaire: ["naps": "no"], build: .init(channel: "AltStore", signed: false), - storage: .init(dbBytes: 1024, rows: ["sleep_sessions": 12], rawCaptureBytes: 2048), + storage: .init(dbBytes: 1024, rows: ["sleep_sessions": 12], rawCaptureBytes: 2048, + rowBytes: ["sleep_sessions": 4096]), redaction: "v2", truncated: false) } diff --git a/StrandTests/TodayVitalCardTests.swift b/StrandTests/TodayVitalCardTests.swift index fa68c191f9..2a159df12e 100644 --- a/StrandTests/TodayVitalCardTests.swift +++ b/StrandTests/TodayVitalCardTests.swift @@ -72,4 +72,43 @@ final class TodayVitalCardTests: XCTestCase { let days = [day("2026-08-13", resp: 15.0)] XCTAssertNil(Repository.lastRespDay(days: days, todayKey: "2026-08-13")) } + + // MARK: Steps calibration caption (#1816) + + /// With no coefficient and no motion, the caption must say "No motion synced yet" — NOT the + /// phone-step-days countdown, because the motion half is the blocker and no number of phone-step + /// days will move the estimate or the fit. The old caption sent a field reporter to enter Apple + /// Health steps by hand expecting calibration to start, which it cannot without strap motion. + func testStepsCaptionNoMotionSaysNoMotionNotPhoneDays() { + let caption = TodayView.stepsCalibrationCaption(coefficient: 0, manualCoefficient: 0, + hasBankedMotion: false, sampleDays: 0) + XCTAssertEqual(caption, "No motion synced yet") + } + + /// With no coefficient but motion HAS been banked, the caption returns the engine's + /// needsMoreDays headline — the phone half is genuinely the missing half now. + func testStepsCaptionWithMotionReturnsPhoneDaysCountdown() { + let caption = TodayView.stepsCalibrationCaption(coefficient: 0, manualCoefficient: 0, + hasBankedMotion: true, sampleDays: 0) + XCTAssertNotNil(caption) + XCTAssertNotEqual(caption, "No motion synced yet", + "with motion banked, the phone-step-days countdown is the honest message") + } + + /// Once a coefficient exists (auto or manual), the caption is nil — a blank day is just a quiet + /// one below the motion floor, not a missing input, and there is nothing for the user to go do. + func testStepsCaptionNilOnceCalibrated() { + XCTAssertNil(TodayView.stepsCalibrationCaption(coefficient: 1.5, manualCoefficient: 0, + hasBankedMotion: false, sampleDays: 3)) + XCTAssertNil(TodayView.stepsCalibrationCaption(coefficient: 0, manualCoefficient: 2.0, + hasBankedMotion: false, sampleDays: 0)) + } + + /// The no-motion message takes precedence over the phone-step-days countdown even when some + /// sample days have been recorded, because the motion half is still the blocker. + func testStepsCaptionNoMotionPrecedenceOverSampleDays() { + let caption = TodayView.stepsCalibrationCaption(coefficient: 0, manualCoefficient: 0, + hasBankedMotion: false, sampleDays: 2) + XCTAssertEqual(caption, "No motion synced yet") + } } diff --git a/StrandTests/TodayWorkoutsWindowTests.swift b/StrandTests/TodayWorkoutsWindowTests.swift new file mode 100644 index 0000000000..843f2e93b3 --- /dev/null +++ b/StrandTests/TodayWorkoutsWindowTests.swift @@ -0,0 +1,59 @@ +import XCTest +import WhoopStore +@testable import Strand + +/// #1702: the Today "Latest Workouts" window, pinned to Android's contract. +/// +/// Android windows the feed to `today − 13` at start of day (`TodayScreen.recentCutoff`) and says so in +/// the header. iOS showed the same section from `repo.workoutRows()`, whose default is `days: 4000` — +/// effectively all-time — under a heading that says "Latest", with a trailing count of every workout +/// ever recorded. Same account, opposite readings on the two platforms. +/// +/// These pin the boundary rather than the happy path: an off-by-one here silently drops a session the +/// other platform shows, which is exactly the class of drift that produced the issue. +final class TodayWorkoutsWindowTests: XCTestCase { + + /// Fixed clock so "13 days back, start of day" is not evaluated against a moving now. + private let now = Date(timeIntervalSince1970: 1_756_000_000) // 2025-08-24T02:26:40Z + + /// WorkoutRow takes every field on purpose (#1444), so spell them all out. + private func row(startingAt ts: Int) -> WorkoutRow { + WorkoutRow(startTs: ts, endTs: ts + 1_800, sport: "Running", source: "whoop", + durationS: 1_800, energyKcal: nil, avgHr: nil, maxHr: nil, strain: nil, + distanceM: nil, zonesJSON: nil, notes: nil, steps: nil) + } + + private var cutoffTs: Int { + let cal = Calendar.current + return Int(cal.date(byAdding: .day, value: -13, to: cal.startOfDay(for: now))!.timeIntervalSince1970) + } + + func testKeepsASessionExactlyOnTheCutoff() { + let kept = TodayView.recentWorkoutsFeed([row(startingAt: cutoffTs)], now: now) + XCTAssertEqual(kept.count, 1, "the cutoff instant is inside the window, matching Android's >=") + } + + func testDropsTheSecondBeforeTheCutoff() { + let kept = TodayView.recentWorkoutsFeed([row(startingAt: cutoffTs - 1)], now: now) + XCTAssertTrue(kept.isEmpty) + } + + func testDropsAnAllTimeSessionThatUsedToShow() { + // The behaviour the issue described: a session from months ago listed under "Latest". + let threeMonthsAgo = Int(now.timeIntervalSince1970) - 90 * 86_400 + XCTAssertTrue(TodayView.recentWorkoutsFeed([row(startingAt: threeMonthsAgo)], now: now).isEmpty) + } + + func testKeepsTodaysSession() { + let kept = TodayView.recentWorkoutsFeed([row(startingAt: Int(now.timeIntervalSince1970))], now: now) + XCTAssertEqual(kept.count, 1) + } + + func testPreservesInputOrderAndDropsOnlyTheStale() { + let recentA = Int(now.timeIntervalSince1970) - 3_600 + let recentB = Int(now.timeIntervalSince1970) - 7_200 + let stale = cutoffTs - 86_400 + let kept = TodayView.recentWorkoutsFeed([row(startingAt: recentA), row(startingAt: stale), row(startingAt: recentB)], now: now) + XCTAssertEqual(kept.map(\.startTs), [recentA, recentB], "filtering must not reorder the feed") + } +} diff --git a/StrandTests/UnitFormatterTests.swift b/StrandTests/UnitFormatterTests.swift index 0fce9073aa..faa67a75a1 100644 --- a/StrandTests/UnitFormatterTests.swift +++ b/StrandTests/UnitFormatterTests.swift @@ -76,6 +76,13 @@ final class UnitFormatterTests: XCTestCase { XCTAssertEqual(UnitFormatter.distanceFromKilometers(12.4, system: .imperial), "7.7 mi") } + func testSpeedFollowsExerciseDistanceSystem() { + XCTAssertEqual(UnitFormatter.speedFromKilometersPerHour(10, system: .metric), "10.0 km/h") + XCTAssertEqual(UnitFormatter.speedFromKilometersPerHour(10, system: .imperial), "6.2 mph") + XCTAssertNil(UnitFormatter.speedFromKilometersPerHour(nil, system: .metric)) + XCTAssertNil(UnitFormatter.speedFromKilometersPerHour(-1, system: .imperial)) + } + // MARK: - Mass formatting func testMassFromKilograms() { @@ -118,6 +125,56 @@ final class UnitFormatterTests: XCTestCase { XCTAssertEqual(UnitFormatter.temperatureDeltaFromCelsius(0.6, unit: .celsius), "0.6 °C") } + /// The Double-returning delta converter and the String one must not drift: callers that need + /// locale-aware decimals (the illness-signal label formats through AppLanguage.activeLocale) use the + /// Double form, and a second copy of the 9/5 rule is exactly what that split invites. + func testDeltaConverterAgreesWithTheFormatter() { + XCTAssertEqual(UnitFormatter.celsiusDeltaToFahrenheit(0.6), 1.08, accuracy: 1e-9) + XCTAssertEqual(UnitFormatter.celsiusDeltaToFahrenheit(-4.2), -7.56, accuracy: 1e-9) + XCTAssertEqual(UnitFormatter.celsiusDeltaToFahrenheit(0), 0, accuracy: 1e-9) + for c in stride(from: -5.0, through: 5.0, by: 0.25) { + XCTAssertEqual( + String(format: "%.1f", UnitFormatter.celsiusDeltaToFahrenheit(c)) + " °F", + UnitFormatter.temperatureDeltaFromCelsius(c, unit: .fahrenheit), + "diverged at \(c) °C") + } + } + + /// A delta must never pick up the +32: at 0 the two conversions differ by exactly the offset, which + /// is what made a −4.2 °C deviation read as a fever once before (#111/#622). + func testDeltaAndAbsoluteConversionsAreDistinct() { + XCTAssertEqual(UnitFormatter.celsiusToFahrenheit(0), 32.0, accuracy: 1e-9) + XCTAssertEqual(UnitFormatter.celsiusDeltaToFahrenheit(0), 0.0, accuracy: 1e-9) + } + + /// The illness-signal phrase. Expected strings generated by compiling SkinTempDisplay standalone + /// with swiftc and pasted verbatim — the oracle rule, not arithmetic done by eye. + func testSkinTempSignalPhraseHandlesBothModes() { + // Deviation (live BLE): signed, "Δ" chip, ×9/5 with NO offset. + XCTAssertEqual(UnitFormatter.skinTempSignalPhrase(0.7, fahrenheit: false, locale: nil), "+0.7 Δ°C") + XCTAssertEqual(UnitFormatter.skinTempSignalPhrase(0.7, fahrenheit: true, locale: nil), "+1.3 Δ°F") + // Absolute (CSV / Apple Health): unsigned, bare chip, +32 offset applied. + XCTAssertEqual(UnitFormatter.skinTempSignalPhrase(34.1, fahrenheit: false, locale: nil), "34.1 °C") + XCTAssertEqual(UnitFormatter.skinTempSignalPhrase(34.1, fahrenheit: true, locale: nil), "93.4 °F") + } + + /// The 20 °C boundary decides the mode, and it belongs to the ABSOLUTE side. + func testSkinTempSignalPhraseSplitsAtTwentyDegrees() { + XCTAssertEqual(UnitFormatter.skinTempSignalPhrase(19.9, fahrenheit: false, locale: nil), "+19.9 Δ°C") + XCTAssertEqual(UnitFormatter.skinTempSignalPhrase(20.0, fahrenheit: false, locale: nil), "20.0 °C") + } + + /// The whole reason the number is formatted here instead of by SkinTempDisplay: the banner passes + /// AppLanguage.activeLocale, and a German reader must see "0,7" rather than "0.7". + func testSkinTempSignalPhraseHonoursTheLocalesDecimalSeparator() { + let de = Locale(identifier: "de_DE") + XCTAssertEqual(UnitFormatter.skinTempSignalPhrase(0.7, fahrenheit: false, locale: de), "+0,7 Δ°C") + XCTAssertEqual(UnitFormatter.skinTempSignalPhrase(34.1, fahrenheit: false, locale: de), "34,1 °C") + XCTAssertEqual( + UnitFormatter.skinTempSignalPhrase(0.7, fahrenheit: true, locale: Locale(identifier: "fr_FR")), + "+1,3 Δ°F") + } + // #111/#622: skin_temp holds EITHER a signed deviation (v < 20 °C) or an absolute reading (v >= 20 °C). // A DEVIATION converts ×9/5 with NO +32 (the absolute formula turned a −4.2 °C deviation into the // nonsense "24.4 °F") and is labelled as a signed baseline delta — "Δ°C" / "Δ°F", sign always shown — @@ -148,6 +205,14 @@ final class UnitFormatterTests: XCTestCase { XCTAssertEqual(UnitPrefs.resolveTemperature(system: .metric, override: "fahrenheit"), .fahrenheit) } + func testDistanceSystemFallsBackToLegacyPreference() { + XCTAssertEqual(UnitPrefs.resolveDistance(system: .metric, override: ""), .metric) + XCTAssertEqual(UnitPrefs.resolveDistance(system: .imperial, override: ""), .imperial) + XCTAssertEqual(UnitPrefs.resolveDistance(system: .imperial, override: "metric"), .metric) + XCTAssertEqual(UnitPrefs.resolveDistance(system: .metric, override: "imperial"), .imperial) + XCTAssertEqual(UnitPrefs.resolveDistance(system: .imperial, override: "unknown"), .imperial) + } + // MARK: - Unit labels func testUnitLabels() { diff --git a/StrandTests/UpdateAvailabilityTests.swift b/StrandTests/UpdateAvailabilityTests.swift new file mode 100644 index 0000000000..c49ff9f48d --- /dev/null +++ b/StrandTests/UpdateAvailabilityTests.swift @@ -0,0 +1,161 @@ +import XCTest +@testable import Strand + +/// The #1659 automatic update check. +/// +/// iOS cannot auto-update a sideloaded build — no API lets an app install or re-sign an `.ipa` — so the +/// most NOOP can do is notice a release and say so. These rules decide when it may look, and when the +/// result is worth a row in the Updates inbox. +final class UpdateAvailabilityTests: XCTestCase { + + private let day = UpdateAvailability.checkInterval + + // MARK: - when it may look + + /// Off by default, and off means SILENT: no request, no line, no trace. That is what keeps the + /// feature compatible with the project's offline promise. + func testDisabledNeverChecks() { + XCTAssertFalse(UpdateAvailability.shouldCheckNow(enabled: false, lastCheckedAt: 0, now: 1_000_000)) + XCTAssertFalse(UpdateAvailability.shouldCheckNow(enabled: false, lastCheckedAt: 1, now: 1_000_000)) + } + + /// Turning the toggle on should answer during THAT session, not tomorrow. + func testANeverCheckedInstallIsDueImmediately() { + XCTAssertTrue(UpdateAvailability.shouldCheckNow(enabled: true, lastCheckedAt: 0, now: 1_000_000)) + } + + func testItWaitsOutTheInterval() { + let t = 1_000_000.0 + XCTAssertFalse(UpdateAvailability.shouldCheckNow(enabled: true, lastCheckedAt: t, now: t + day - 1)) + XCTAssertTrue(UpdateAvailability.shouldCheckNow(enabled: true, lastCheckedAt: t, now: t + day)) + XCTAssertTrue(UpdateAvailability.shouldCheckNow(enabled: true, lastCheckedAt: t, now: t + day * 3)) + } + + /// A clock that moves BACKWARDS — timezone edit, NTP correction, a restored backup — would otherwise + /// park the next check arbitrarily far in the future. Treating it as due self-heals on the next write. + func testAClockGoingBackwardsDoesNotStrandTheCheck() { + let t = 1_000_000.0 + XCTAssertTrue(UpdateAvailability.shouldCheckNow(enabled: true, lastCheckedAt: t, now: t - 5)) + XCTAssertTrue(UpdateAvailability.shouldCheckNow(enabled: true, lastCheckedAt: t, now: t - day * 400)) + } + + // MARK: - when it is worth saying + + func testItPostsOnlyForANewerVersion() { + XCTAssertTrue(UpdateAvailability.shouldPost(latest: "10.7.0", current: "10.6.0", lastPostedVersion: nil)) + XCTAssertFalse(UpdateAvailability.shouldPost(latest: "10.6.0", current: "10.6.0", lastPostedVersion: nil)) + XCTAssertFalse(UpdateAvailability.shouldPost(latest: "10.5.0", current: "10.6.0", lastPostedVersion: nil)) + } + + /// ONCE PER VERSION. On iOS acting means AltStore or a re-sign, so a user may reasonably read the row + /// and do nothing — and an app that repeats itself daily about something the user has already seen + /// teaches people to ignore the bell, which costs more than the feature is worth. + func testItNeverNagsAboutAVersionAlreadyPosted() { + XCTAssertFalse(UpdateAvailability.shouldPost(latest: "10.7.0", current: "10.6.0", + lastPostedVersion: "10.7.0")) + // …but the NEXT release is new news again. + XCTAssertTrue(UpdateAvailability.shouldPost(latest: "10.8.0", current: "10.6.0", + lastPostedVersion: "10.7.0")) + } + + /// The counters are versions, not booleans: a user who skipped 10.7.0 and is still on 10.6.0 must + /// still hear about 10.8.0. + func testSkippingAReleaseDoesNotSilenceTheNextOne() { + XCTAssertTrue(UpdateAvailability.shouldPost(latest: "11.0.0", current: "10.6.0", + lastPostedVersion: "10.7.0")) + } + + // MARK: - the inbox row + + /// `.newVersion` must be INFORMATIONAL so it dedupes and can be evicted. It cannot spam on its own, + /// but a user who ignores several releases should not have them crowd out the actionable rows. + func testTheRowIsInformationalAndDistinctFromWhatsNew() { + let item = UpdateItem(kind: .newVersion, title: "t", message: "m") + XCTAssertEqual(item.kind, .newVersion) + XCTAssertNotEqual(item.kind, .whatsNew) + // Round-trips through the persisted store's Codable rawValue. + XCTAssertEqual(UpdateItem.Kind(rawValue: "newVersion"), .newVersion) + } + + // MARK: - pruning a stale announcement + + /// The row says "10.7.0 is available". Once the user installs 10.7.0 that sentence is false, and it + /// sits beside the What's New row for the same version — an app telling you to get what you have. + func testTheAnnouncementIsPrunedOnceInstalled() { + XCTAssertTrue(UpdateAvailability.shouldPruneAnnouncement(lastPostedVersion: "10.7.0", current: "10.7.0")) + // Overshot it (a user who jumped straight to 10.8.0) — equally stale. + XCTAssertTrue(UpdateAvailability.shouldPruneAnnouncement(lastPostedVersion: "10.7.0", current: "10.8.0")) + } + + /// Still behind: the row is still TRUE and must survive, or the announcement would delete itself on + /// the very next launch and the feature would do nothing at all. + func testAnAnnouncementStillAheadSurvives() { + XCTAssertFalse(UpdateAvailability.shouldPruneAnnouncement(lastPostedVersion: "10.7.0", current: "10.6.0")) + } + + func testNothingAnnouncedIsNothingToPrune() { + XCTAssertFalse(UpdateAvailability.shouldPruneAnnouncement(lastPostedVersion: nil, current: "10.6.0")) + XCTAssertFalse(UpdateAvailability.shouldPruneAnnouncement(lastPostedVersion: "", current: "10.6.0")) + } + + // MARK: - assembling the row body + + /// The COPY is localized at the platform edge now, so what is pinned here is the ASSEMBLY: order, the + /// single space between sentences, the blank line before notes, and the trimming. A first cut tested + /// the English strings byte-for-byte against Kotlin — the wrong property, and one that would have + /// stayed green while the row rendered in English inside a translated inbox. + func testComposeJoinsSentencesWithASingleSpace() { + XCTAssertEqual(UpdateAvailability.composeMessage(body: "A.", sideload: "B.", notes: ""), "A. B.") + } + + func testComposeOmitsAnAbsentOrEmptySideloadSentence() { + XCTAssertEqual(UpdateAvailability.composeMessage(body: "A.", sideload: nil, notes: ""), "A.") + XCTAssertEqual(UpdateAvailability.composeMessage(body: "A.", sideload: "", notes: ""), "A.") + } + + /// Release notes are a block, not a sentence: blank line before, and trimmed — GitHub bodies arrive + /// with padding. + func testComposeAppendsTrimmedNotesAfterABlankLine() { + XCTAssertEqual(UpdateAvailability.composeMessage(body: "A.", sideload: nil, notes: " N. "), + "A.\n\nN.") + XCTAssertEqual(UpdateAvailability.composeMessage(body: "A.", sideload: "B.", notes: "N."), + "A. B.\n\nN.") + } + + /// Whitespace-only notes must not leave a trailing blank line hanging in the row. + func testComposeTreatsBlankNotesAsNone() { + XCTAssertEqual(UpdateAvailability.composeMessage(body: "A.", sideload: nil, notes: " \n "), "A.") + } + + // MARK: - the default + + /// An UNSET pref must resolve to the same answer the Settings toggle shows. The launch check reads + /// UserDefaults directly and the toggle reads @AppStorage; both fall back to `defaultEnabled`, and if + /// one were changed without the other the switch would say one thing while the app did another. + func testAnUnsetPrefMatchesWhatTheToggleWouldShow() { + let key = UpdateWatch.Keys.enabled + let saved = UserDefaults.standard.object(forKey: key) + defer { + if let saved { UserDefaults.standard.set(saved, forKey: key) } + else { UserDefaults.standard.removeObject(forKey: key) } + } + UserDefaults.standard.removeObject(forKey: key) + XCTAssertEqual(UpdateWatch.isEnabled, UpdateAvailability.defaultEnabled) + } + + /// An explicit choice always wins over the default, in BOTH directions — otherwise flipping the + /// shipped default would silently override people who had already opted out. + func testAnExplicitChoiceOverridesTheDefault() { + let key = UpdateWatch.Keys.enabled + let saved = UserDefaults.standard.object(forKey: key) + defer { + if let saved { UserDefaults.standard.set(saved, forKey: key) } + else { UserDefaults.standard.removeObject(forKey: key) } + } + UserDefaults.standard.set(false, forKey: key) + XCTAssertFalse(UpdateWatch.isEnabled) + UserDefaults.standard.set(true, forKey: key) + XCTAssertTrue(UpdateWatch.isEnabled) + } +} + diff --git a/StrandTests/VitalReadingsTableTests.swift b/StrandTests/VitalReadingsTableTests.swift index 74fca102fc..6fef98c56e 100644 --- a/StrandTests/VitalReadingsTableTests.swift +++ b/StrandTests/VitalReadingsTableTests.swift @@ -87,4 +87,39 @@ final class VitalReadingsTableTests: XCTestCase { ) XCTAssertEqual(rows.first?.value, "72") } + + // MARK: - #1942: the Explorer pairs a unit-BEARING formatter with an EMPTY unit + + /// Every test above passes `spo2Format`, a unit-LESS closure, which is the contract this function was + /// written to and the one all of Android's call sites follow. The Explorer cannot follow it: its + /// formatter is `MetricDescriptor.format`, which already ends in the unit, and the CONVERTED one — + /// there is no unit-less iOS formatter that also converts kg→lb / °C→°F / 0–100→0–21. So it pairs that + /// formatter with an empty unit, and this pins both halves of that pairing. + /// + /// Passing the descriptor's stored `unit` alongside it is what rendered "33 % %" in the readings table + /// while the hero and the stat tiles, which render the formatter directly, stayed correct (#1942). + func testExplorerFormatterAlreadyCarriesTheUnitSoTheUnitParameterIsEmpty() throws { + let spo2 = try XCTUnwrap(MetricCatalog.metric(key: "spo2", source: strap)) + let explorerFormat: (Double) -> String = { spo2.format($0, system: .metric, temperature: .celsius) } + + // Half one: the Explorer's own formatter is unit-BEARING, unlike `spo2Format` above. + XCTAssertEqual(explorerFormat(97), "97 %") + + // Half two: paired with an empty unit, the row carries exactly one. + let rows = vitalReadingRows(readings: spo2Readings(), unit: "", strapDeviceId: strap, + now: now, format: explorerFormat) + XCTAssertEqual(rows.first?.value, "97 %") + } + + /// The function itself is not at fault, and this says so: given the pairing the Explorer used to pass, + /// doubling is the CORRECT output. That is why the fix is at the call site and why Android, whose + /// twin appends identically, was never affected. Kept as the counter-case to the test above so a + /// future reader restoring `unit: metric.unit` sees what it does. + func testAUnitBearingFormatterPlusAUnitDoublesItWhichIsWhyTheCallSitePassesEmpty() throws { + let spo2 = try XCTUnwrap(MetricCatalog.metric(key: "spo2", source: strap)) + let rows = vitalReadingRows(readings: spo2Readings(), unit: spo2.unit, strapDeviceId: strap, + now: now, + format: { spo2.format($0, system: .metric, temperature: .celsius) }) + XCTAssertEqual(rows.first?.value, "97 % %") + } } diff --git a/StrandTests/Whoop5BatteryBackfillThrottleTests.swift b/StrandTests/Whoop5BatteryBackfillThrottleTests.swift index 1b464af84b..863a005d67 100644 --- a/StrandTests/Whoop5BatteryBackfillThrottleTests.swift +++ b/StrandTests/Whoop5BatteryBackfillThrottleTests.swift @@ -119,4 +119,24 @@ final class Whoop5BatteryBackfillThrottleTests: XCTestCase { // The first read of a connection still always fires, charging or not. XCTAssertTrue(BLEManager.shouldPollWhoop5Battery(lastReadAt: nil, now: now, charging: false)) } + + // MARK: - #1953: unbonded 5/MG battery read parity + + /// #1953: an unbonded 5/MG (the #1635 suppressed-hello case) gets a periodic 0x2A19 battery read on + /// Android's `keepAliveFire` but not on iOS — the tick runs (via `keepAliveMayRun`'s + /// `bonded && .whoop5` branch) but the read lives inside `enableLiveNotifications`, which is + /// `didBond`-gated. The fix reuses THIS throttle on the unbonded path so the two platforms read at + /// the same cadence without doubling the rate on a bonded strap. These pin the throttle's contract + /// for that new caller: the gate is the same, only the call site differs. + func testUnbondedBatteryReadUsesSameThrottle() { + // First read of a connection — always fires, bonded or not. + XCTAssertTrue(BLEManager.shouldPollWhoop5Battery(lastReadAt: nil)) + // Under the floor — blocked, so an unbonded 5/MG is not re-read every 30 s tick. + let now = Date() + let last = now.addingTimeInterval(-29) + XCTAssertFalse(BLEManager.shouldPollWhoop5Battery(lastReadAt: last, now: now)) + // At the floor — fires, matching Android's ~60 s cadence. + let last2 = now.addingTimeInterval(-BLEManager.whoop5BatteryReadMinIntervalSeconds) + XCTAssertTrue(BLEManager.shouldPollWhoop5Battery(lastReadAt: last2, now: now)) + } } diff --git a/StrandTests/Whoop5BatteryPollGuardTests.swift b/StrandTests/Whoop5BatteryPollGuardTests.swift new file mode 100644 index 0000000000..baf9cb9e86 --- /dev/null +++ b/StrandTests/Whoop5BatteryPollGuardTests.swift @@ -0,0 +1,66 @@ +import XCTest +@testable import Strand + +/// #1948: the 5/MG keep-alive must not send battery commands the allowlist refuses. +/// +/// Both commands that block used to send are rejected on a 5/MG before they leave the app: the send +/// allowlist has no clause for `.getBatteryLevel` at all, and admits `.getBatteryPackInfo` only while a +/// user-initiated probe is in flight. So a 5/MG paid two dead sends and two skip lines per tick, under a +/// comment claiming the pack "rides the SAME cadence as the strap's own gauge". +/// +/// A 5/MG needs neither: its percent comes from the 0x2A19 read `enableLiveNotifications` drives off this +/// same keep-alive, and its pack charge from the pushed pack-info event (109). +/// +/// Pinned against the SOURCE, the way `DeviceRawSourceParityTests` pins its oracle, because the send sits +/// in a keep-alive body no unit test can drive. The Kotlin twin guard is in `ChargingAndReleaseTest`. +final class Whoop5BatteryPollGuardTests: XCTestCase { + + private func managerSource() throws -> String { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + return try String(contentsOf: root.appendingPathComponent("Strand/BLE/BLEManager.swift"), + encoding: .utf8) + } + + /// Whitespace-normalised on both sides. A Swift multiline literal strips indentation to its closing + /// delimiter, so a literal written here could never carry the source's own leading spaces, and + /// comparing raw text would fail for a reason that has nothing to do with the guard. + private func normalised(_ s: String) -> String { + s.split(whereSeparator: { $0.isWhitespace }).joined(separator: " ") + } + + /// The poll is family-guarded, and the guard is on the SAME statement as the send — a nearby `if` + /// that merely reads well would not stop the traffic. + func testTheBatteryPollIsGuardedAwayFromWhoop5() throws { + let src = try normalised(managerSource()) + let expected = normalised(""" + if selectedModel.deviceFamily != .whoop5, + BLEManager.batteryPollDue(tick: keepAliveTick, charging: state.charging == true) { + send(.getBatteryLevel, payload: []) + } + """) + XCTAssertTrue(src.contains(expected), + "the keep-alive battery poll must be guarded away from the 5/MG (#1948)") + } + + /// Nothing may send the pack opcode on a cadence. The user-initiated probe still may, so this asserts + /// the absence in the keep-alive rather than in the file, which would forbid the probe too. + func testNoCadencedPackInfoSendSurvives() throws { + let src = try managerSource() + // The WHOLE keep-alive, not just the battery block inside it. Anchoring at the battery comment + // covered only what follows it, so a pack send added earlier in the same function would have + // slipped past while the assertion still read as if it guarded the tick. The Kotlin twin was + // widened for the mirror-image reason, and a control insertion proved it there. + guard let start = src.range(of: "private func keepAliveFire() {"), + let end = src.range(of: "private func startBackfillTimer") else { + return XCTFail("the keep-alive function or its next landmark was not found") + } + // Comments in this block name the opcode while explaining why it is not sent, so judge the CODE + // only — the same rule the Kotlin twin follows. Without this the guard fails on its own rationale. + let block = String(src[start.lowerBound.. 5 { WidgetTelemetry.noteDeclined() } else { WidgetTelemetry.noteReloaded(now: at) } + } + let s = WidgetTelemetry.snapshot(now: t0.addingTimeInterval(660)) + XCTAssertEqual(s.admitted, 11) + XCTAssertEqual(s.reloaded, 5) + XCTAssertEqual(s.declined, 5) + // Five reloads in a ten-minute steady window is 30/h, not the 60/h the publishes would claim. + XCTAssertEqual(s.reloadsPerHour ?? 0, 30, accuracy: 0.001) + } + + /// The startup burst must not be quoted as a rate. The snapshot's fields arrive one by one at + /// launch and each publishes on the spot, so a launch produces activity the once-a-minute throttle + /// had nothing to do with. An Android capture reported six in a hundred seconds as 215/h against a + /// steady state of about sixty. + func testAStartupBurstIsNotQuotedAsARate() { + for offset in [0.0, 2, 5, 9, 30, 95] { + WidgetTelemetry.noteAdmitted(now: t0.addingTimeInterval(offset)) + WidgetTelemetry.noteReloaded(now: t0.addingTimeInterval(offset)) + } + let s = WidgetTelemetry.snapshot(now: t0.addingTimeInterval(100)) + XCTAssertEqual(s.admitted, 6) + XCTAssertNil(s.reloadsPerHour, "a burst is not a rate") + XCTAssertTrue(s.render().contains("steady"), s.render()) + } + + /// The withheld-rate message has to name the window it waits on. Phrasing it as a minimum UPTIME + /// was wrong on Android: publishes can be sparse enough that the steady window opens late, so the + /// line would sit twenty minutes in still claiming it needed six. + func testAWithheldRateNamesTheSteadyWindow() { + WidgetTelemetry.noteAdmitted(now: t0) + WidgetTelemetry.noteAdmitted(now: t0.addingTimeInterval(19 * 60)) + let line = WidgetTelemetry.snapshot(now: t0.addingTimeInterval(20 * 60)).render() + XCTAssertTrue(line.contains("over 20m"), line) + XCTAssertTrue(line.contains("steady 1m of 5m"), line) + } + + /// The absence of widget activity is itself an answer to a drain report, so it must be stated + /// rather than rendered as an empty line. + func testASessionWithNoPublishesSaysSo() { + XCTAssertEqual(WidgetTelemetry.snapshot(now: t0).render(), + "Widgets: no publishes this app session") + } + + /// The rate is a diagnostics field read by eye and by grep, so it must not pick up a locale's + /// decimal comma. + func testTheRateFormatsWithADot() { + WidgetTelemetry.noteAdmitted(now: t0) + for m in 1...10 { + let at = t0.addingTimeInterval(Double(m) * 60) + WidgetTelemetry.noteAdmitted(now: at) + WidgetTelemetry.noteReloaded(now: at) + } + let line = WidgetTelemetry.snapshot(now: t0.addingTimeInterval(660)).render() + XCTAssertTrue(line.contains("60.0/h"), line) + XCTAssertFalse(line.contains("60,0/h"), line) + } + + /// The widget-removed export is half of the comparison the counters exist for, so a reload with + /// nothing installed must not read as one. Android had exactly this bug; iOS never checks whether + /// a widget is placed before calling `reloadAllTimelines`, so without this the figure would be + /// just as blind here. + func testAReloadWithNoWidgetInstalledIsNotCountedAsOne() { + WidgetTelemetry.noteWidgetsInstalled(false) + WidgetTelemetry.noteAdmitted(now: t0) + for m in 1...10 { + let at = t0.addingTimeInterval(Double(m) * 60) + WidgetTelemetry.noteAdmitted(now: at) + WidgetTelemetry.noteNoWidget() + } + let s = WidgetTelemetry.snapshot(now: t0.addingTimeInterval(660)) + XCTAssertEqual(s.admitted, 11) + XCTAssertEqual(s.reloaded, 0) + XCTAssertEqual(s.noWidget, 10) + XCTAssertEqual(s.reloadsPerHour ?? -1, 0, accuracy: 0.001) + XCTAssertTrue(s.render().contains("10 with no widget installed"), s.render()) + } + + /// Unknown must count as installed. Over-reporting reloads is the safe direction for a figure + /// whose whole purpose is to show a cost: a wrong "nothing was spent" is the one answer that would + /// end an investigation early. + func testPresenceDefaultsToInstalledBeforeWidgetKitHasAnswered() { + XCTAssertTrue(WidgetTelemetry.widgetsInstalled) + WidgetTelemetry.noteWidgetsInstalled(false) + XCTAssertFalse(WidgetTelemetry.widgetsInstalled) + WidgetTelemetry.resetForTest() + XCTAssertTrue(WidgetTelemetry.widgetsInstalled, "reset returns it to unknown, not to false") + } +} diff --git a/StrandTests/WindDownPerDayOverrideTests.swift b/StrandTests/WindDownPerDayOverrideTests.swift index 2533fe805a..5b47c3e0cc 100644 --- a/StrandTests/WindDownPerDayOverrideTests.swift +++ b/StrandTests/WindDownPerDayOverrideTests.swift @@ -80,4 +80,60 @@ final class WindDownPerDayOverrideTests: XCTestCase { WindDownNudge.setWakeOverride(weekday: 9, minutes: 8 * 60) // no weekday 9 XCTAssertFalse(WindDownNudge.hasPerDayOverrides) } + + // MARK: - #1863: day shift and weekday pinning + + func testShiftedWeekday_wrapsThroughWeekEnd() { + // Sunday (1) − 1 → Saturday (7); Monday (2) − 1 → Sunday (1); no shift is identity. + XCTAssertEqual(WindDownNudge.shiftedWeekday(weekday: 1, by: -1), 7) + XCTAssertEqual(WindDownNudge.shiftedWeekday(weekday: 2, by: -1), 1) + XCTAssertEqual(WindDownNudge.shiftedWeekday(weekday: 3, by: -1), 2) + XCTAssertEqual(WindDownNudge.shiftedWeekday(weekday: 7, by: -1), 6) + for weekday in 1...7 { + XCTAssertEqual(WindDownNudge.shiftedWeekday(weekday: weekday, by: 0), weekday) + } + } + + func testNudgeDayShift_earlyWake_isPreviousDay() { + // Tuesday 03:30 wake, default 8h need + 30m lead: raw = 210 − 480 − 30 = −300 → shift −1. + WindDownNudge.setWakeMinutes(7 * 60) + WindDownNudge.setWakeOverride(weekday: 3, minutes: 3 * 60 + 30) // Tuesday 03:30 + XCTAssertEqual(WindDownNudge.nudgeDayShift(forWeekday: 3), -1) + // The wrapped minute is 19:00 the previous evening. + XCTAssertEqual(WindDownNudge.nudgeMinuteOfDay(forWeekday: 3), 19 * 60) + } + + func testNudgeDayShift_normalWake_isPreviousDay() { + // 07:00 wake, default 8h need + 30m lead: raw = 420 − 480 − 30 = −90 → shift −1. + WindDownNudge.setWakeMinutes(7 * 60) + XCTAssertEqual(WindDownNudge.nudgeDayShift(forWeekday: 3), -1) + } + + func testNudgeDayShift_lateWake_isSameDay() { + // 09:00 wake, default 8h need + 30m lead: raw = 540 − 480 − 30 = 30 → shift 0. + WindDownNudge.setWakeMinutes(9 * 60) + XCTAssertEqual(WindDownNudge.nudgeDayShift(forWeekday: 3), 0) + } + + func testEarlyWakeNudge_pinnedToPreviousDay() { + // #1863 scenario: Tuesday (weekday 3) 03:30 wake → nudge at 19:00 on Monday (weekday 2). + WindDownNudge.setWakeMinutes(7 * 60) + WindDownNudge.setWakeOverride(weekday: 3, minutes: 3 * 60 + 30) // Tuesday 03:30 + let shift = WindDownNudge.nudgeDayShift(forWeekday: 3) + let minute = WindDownNudge.nudgeMinuteOfDay(forWeekday: 3) + let nudgeWeekday = WindDownNudge.shiftedWeekday(weekday: 3, by: shift) + XCTAssertEqual(shift, -1) + XCTAssertEqual(minute, 19 * 60) // 19:00 + XCTAssertEqual(nudgeWeekday, 2) // Monday, not Tuesday + } + + func testEarlyWakeOnSunday_pinnedToSaturday() { + // Sunday (weekday 1) 03:30 wake → nudge at 19:00 on Saturday (weekday 7). + WindDownNudge.setWakeMinutes(7 * 60) + WindDownNudge.setWakeOverride(weekday: 1, minutes: 3 * 60 + 30) // Sunday 03:30 + let shift = WindDownNudge.nudgeDayShift(forWeekday: 1) + let nudgeWeekday = WindDownNudge.shiftedWeekday(weekday: 1, by: shift) + XCTAssertEqual(shift, -1) + XCTAssertEqual(nudgeWeekday, 7) // Saturday, wrapping the week end + } } diff --git a/StrandiOS/App/AppModel+iOS.swift b/StrandiOS/App/AppModel+iOS.swift index 5b706e3751..56e7589033 100644 --- a/StrandiOS/App/AppModel+iOS.swift +++ b/StrandiOS/App/AppModel+iOS.swift @@ -2,9 +2,10 @@ import Foundation extension AppModel { - /// Execute any actions queued by App Intents while the app was suspended (mark moment, buzz). - /// Call when the app becomes active. - func drainPendingIntents() { + /// Execute any actions queued by App Intents while the app was suspended (mark moment, buzz, + /// ask coach). Call when the app becomes active. The optional `router` lets the ask-coach + /// intent navigate to the Coach tab after sending the question. + func drainPendingIntents(router: NavRouter? = nil) { for item in PendingIntents.drain() { switch item.action { case .markMoment: markMoment(at: item.date ?? Date()) @@ -12,6 +13,16 @@ extension AppModel { // The one-shot routine sends the confirmed pattern + RUN_ALARM sequence, acked, so a // busy just-foregrounded BLE link can't silently drop it. case .buzz: buzzStrapOnce() + // K9: "Ask Coach" via Siri — send the queued question to the Coach engine and navigate + // to the Coach tab so the user sees the response. The question is consumed from a + // dedicated key (one at a time). + case .askCoach: + if let question = PendingIntents.consumeCoachQuestion() { + router?.openCoach() + Task { @MainActor in + await coach.send(question) + } + } } } } diff --git a/StrandiOS/App/CoachVoiceInput.swift b/StrandiOS/App/CoachVoiceInput.swift new file mode 100644 index 0000000000..de61060be5 --- /dev/null +++ b/StrandiOS/App/CoachVoiceInput.swift @@ -0,0 +1,241 @@ +import Foundation +import SwiftUI +#if canImport(UIKit) +import UIKit +import Speech +#endif + +/// K4: On-device speech-to-text wrapper for the Coach composer (iOS only). +/// +/// Design contract (see PRD-K4): +/// - **No raw audio egress.** Recognition is requested with `requiresOnDeviceRecognition = true` +/// wherever the locale supports it. If a locale ONLY supports server recognition, voice is +/// **disabled** for that locale rather than falling back to server transcription — server +/// transcription would ship audio off-device, breaking the offline posture. +/// - The microphone button lives in the shared `CoachView` composer; on macOS this type is a +/// no-op (`isSupported` is `false`, all methods are inert) so the shared file keeps compiling. +/// - Only the resulting TEXT ever reaches the provider, via the same channel a typed question +/// already uses. No new network path is introduced. +/// +/// Usage from `CoachView`: +/// 1. `voice.isSupported` gates whether the mic button is shown at all. +/// 2. `voice.authorizationStatus` drives the button's enabled / "open Settings" state. +/// 3. `voice.startTranscribing(partial:)` begins a session; `partial` is called on the main +/// actor with the live transcript. +/// 4. `voice.stopTranscribing(completion:)` finalizes and returns the final text. +@MainActor +final class CoachVoiceInput: ObservableObject { + + /// Whether voice input is available on this device + locale at all. + static var isSupported: Bool { + #if canImport(UIKit) + return SFSpeechRecognizer.authorizationStatus() != .restricted + && SFSpeechRecognizer.self != nil + && SFSpeechRecognizer(locale: Locale.current) != nil + #else + return false + #endif + } + + #if canImport(UIKit) + /// Current Speech framework authorization status, mapped to a simple enum the UI can switch on. + var authorization: AuthorizationState { + switch SFSpeechRecognizer.authorizationStatus() { + case .authorized: return .authorized + case .denied: return .denied + case .restricted: return .unavailable + case .notDetermined: return .notDetermined + @unknown default: return .unavailable + } + } + #else + var authorization: AuthorizationState { .unavailable } + #endif + + /// Whether a recognition session is currently recording. + @Published private(set) var isRecording: Bool = false + + /// The latest partial transcript (live, while recording). + @Published var partialTranscript: String = "" + + /// A short human-readable status for the disabled-state tooltip (e.g. "On-device speech not + /// available for en-GB"). `nil` when voice is fully available. + @Published var statusMessage: String? + + /// On-device support for the current locale. If `false`, voice is disabled rather than + /// falling back to server recognition (which would be raw-audio egress). + private static var localeSupportsOnDevice: Bool { + #if canImport(UIKit) + guard let recognizer = SFSpeechRecognizer(locale: Locale.current) else { return false } + return recognizer.supportsOnDeviceRecognition + #else + return false + #endif + } + + /// True when voice can be used right now: supported, authorized, and locale supports on-device. + var canUseVoice: Bool { + guard Self.isSupported, authorization == .authorized, Self.localeSupportsOnDevice else { + return false + } + return true + } + + /// Request Speech + Microphone authorization. Safe to call repeatedly; the system no-ops if + /// already decided. `completion` fires on the main actor. + func requestAuthorization(completion: @escaping (AuthorizationState) -> Void) { + #if canImport(UIKit) + // Speech auth first; mic auth piggybacks on the same prompt flow but is a separate grant. + SFSpeechRecognizer.requestAuthorization { speechStatus in + Task { @MainActor in + if speechStatus != .authorized { + completion(self.mapSpeech(speechStatus)) + return + } + // Mic permission is requested lazily by AVAudioEngine on iOS — but we ask up front + // so the user sees one consolidated prompt and the button state is correct before + // the first tap. + self.requestMicPermission { micGranted in + completion(micGranted ? .authorized : .denied) + } + } + } + #else + completion(.unavailable) + #endif + } + + /// Begin a recognition session. `partial` is invoked on the main actor with the live transcript + /// as it streams in. No-op (calls `partial` with "") on macOS / unsupported locales. + func startTranscribing(partial: @escaping (String) -> Void) { + #if canImport(UIKit) + guard canUseVoice else { + statusMessage = Self.localeSupportsOnDevice + ? String(localized: "Voice input unavailable. Check microphone permission in Settings.") + : String(localized: "On-device speech not available for \(Locale.current.identifier).") + return + } + stopTranscribing(completion: { _ in }) + partialTranscript = "" + statusMessage = nil + beginSession(partial: partial) + #else + partial("") + #endif + } + + /// Stop the active session and return the finalized transcript via `completion` (main actor). + func stopTranscribing(completion: @escaping (String) -> Void) { + #if canImport(UIKit) + guard isRecording else { + completion(partialTranscript) + return + } + finishSession(completion: completion) + #else + completion("") + #endif + } + + // MARK: - Private (iOS only) + + #if canImport(UIKit) + private var audioEngine: AVAudioEngine? + private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? + private var recognitionTask: SFSpeechRecognitionTask? + + private func mapSpeech(_ status: SFSpeechRecognizerAuthorizationStatus) -> AuthorizationState { + switch status { + case .authorized: return .authorized + case .denied: return .denied + case .restricted: return .unavailable + case .notDetermined: return .notDetermined + @unknown default: return .unavailable + } + } + + private func requestMicPermission(completion: @escaping (Bool) -> Void) { + let session = AVAudioSession.sharedInstance() + session.requestRecordPermission { granted in + Task { @MainActor in completion(granted) } + } + } + + private func beginSession(partial: @escaping (String) -> Void) { + let audioEngine = AVAudioEngine() + self.audioEngine = audioEngine + + let request = SFSpeechAudioBufferRecognitionRequest() + // K4 hard constraint: on-device ONLY. If the locale doesn't support it, voice is disabled + // upstream — but we set the flag defensively here too so a regression can't slip audio out. + request.requiresOnDeviceRecognition = true + request.shouldReportPartialResults = true + self.recognitionRequest = request + + guard let recognizer = SFSpeechRecognizer(locale: Locale.current) else { + statusMessage = String(localized: "On-device speech not available for \(Locale.current.identifier).") + return + } + + let task = recognizer.recognitionTask(with: request) { [weak self] result, error in + Task { @MainActor in + guard let self else { return } + if let result { + let text = result.bestTranscription.formattedString + self.partialTranscript = text + partial(text) + } + if error != nil || (result?.isFinal ?? false) { + self.finishSession(completion: { _ in }) + } + } + } + self.recognitionTask = task + + do { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.record, mode: .measurement, options: .duckOthers) + try session.setActive(true, options: .notifyOthersOnDeactivation) + + let inputNode = audioEngine.inputNode + let recordingFormat = inputNode.outputFormat(forBus: 0) + inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { buffer, _ in + request.append(buffer) + } + + audioEngine.prepare() + try audioEngine.start() + isRecording = true + } catch { + statusMessage = String(localized: "Couldn't start the microphone: \(error.localizedDescription)") + finishSession(completion: { _ in }) + } + } + + private func finishSession(completion: @escaping (String) -> Void) { + if let audioEngine { + audioEngine.stop() + audioEngine.inputNode.removeTap(onBus: 0) + self.audioEngine = nil + } + recognitionRequest?.endAudio() + recognitionRequest = nil + recognitionTask?.cancel() + recognitionTask = nil + try? AVAudioSession.sharedInstance().setActive( + false, options: .notifyOthersOnDeactivation + ) + let final = partialTranscript + isRecording = false + completion(final) + } + #endif + + /// Simplified authorization state the UI switches on (decoupled from SFSpeech symbols on macOS). + enum AuthorizationState { + case notDetermined + case authorized + case denied + case unavailable + } +} diff --git a/StrandiOS/App/RootTabView.swift b/StrandiOS/App/RootTabView.swift index 52b15fadbd..943a49e484 100644 --- a/StrandiOS/App/RootTabView.swift +++ b/StrandiOS/App/RootTabView.swift @@ -6,6 +6,16 @@ import StrandDesign /// natural analogue is a `TabView` with the most-used screens as tabs and everything else under a /// "More" list. Every screen is the same `StrandDesign`-built view the macOS app uses. struct RootTabView: View { + /// #1841: shared with Android by NAME and meaning, not by storage — the two platforms keep their own + /// stores, exactly as the Clock format setting does. + /// + /// Default FALSE here while Android defaults true, and the divergence is deliberate. Apple's forums + /// report `.tabBarMinimizeBehavior(.onScrollDown)` failing to trigger in tabs built on + /// `NavigationStack(path:)` — which is every primary tab in this file, bound deliberately so a tab + /// root can pop and re-scroll. So this may well be inert on our structure, and defaulting ON would + /// advertise a behaviour that never happens. Off until someone confirms it on an iOS 26 device. + @AppStorage("noop.bottomBarAutoHide") private var bottomBarAutoHide = false + /// External entry points must wait until the mandatory first-run gates have completed. The root owns /// that state; keeping it explicit here prevents this shell's window-level sheet from covering a gate. let homeScreenQuickActionsEnabled: Bool @@ -32,12 +42,12 @@ struct RootTabView: View { /// root view alive, so an at-root re-tap keeps scroll position and never re-runs `.task` /// (#198; the #197 resetID/`.id()` rebuild reset both). Requires the tab roots' first-hop /// links to push `TabRoute`/`MoreDestination` VALUES — closure-destination links bypass the path. - @State private var tabPaths: [NavigationPath] = Array(repeating: NavigationPath(), count: 4) + @State private var tabPaths: [NavigationPath] = Array(repeating: NavigationPath(), count: 5) /// One scroll-to-top token per tab. Bumped when the user re-taps the active tab while it's ALREADY /// at its root — the other half of the iOS convention #197/#198 left unserved (an at-root re-tap was /// a no-op). Threaded into each tab's root via `\.scrollToTopSignal`; ScreenScaffold / LiquidTodayView /// scroll to their top anchor when their tab's token changes. - @State private var scrollTop: [Int] = Array(repeating: 0, count: 4) + @State private var scrollTop: [Int] = Array(repeating: 0, count: 5) /// Which More-tab groups are expanded (S2). Insights + Body stay open at rest; Data + App collapse to /// just their header until tapped. Persisted (#860 item 2): the user's open/closed choice must SURVIVE /// leaving and re-entering the More tab (and relaunch), not reset to the seed every visit. Backed by an @@ -95,7 +105,7 @@ struct RootTabView: View { guard selectedTab != 0 else { return } let dx = v.translation.width, dy = v.translation.height guard abs(dx) > 60, abs(dx) > abs(dy) * 1.6 else { return } - let next = min(3, max(0, selectedTab + (dx < 0 ? 1 : -1))) + let next = min(4, max(0, selectedTab + (dx < 0 ? 1 : -1))) if next != selectedTab { withAnimation(.timingCurve(0.22, 1, 0.36, 1, duration: 0.24)) { selectedTab = next } } @@ -110,9 +120,16 @@ struct RootTabView: View { tab(todayTabRoot, "Today", "square.grid.2x2", path: $tabPaths[0], scrollSignal: scrollTop[0]).tag(0) tab(TrendsView(), "Trends", "chart.line.uptrend.xyaxis", path: $tabPaths[1], scrollSignal: scrollTop[1]).tag(1) tab(SleepView(), "Sleep", "bed.double", path: $tabPaths[2], scrollSignal: scrollTop[2]).tag(2) - moreTab(path: $tabPaths[3], scrollSignal: scrollTop[3]).tag(3) + // K3: Coach promoted to a top-level tab (was behind the More list). The sparkles icon + // matches the More-tab row and the macOS sidebar entry. + tab(CoachView(), "Coach", "sparkles", path: $tabPaths[3], scrollSignal: scrollTop[3]).tag(3) + moreTab(path: $tabPaths[4], scrollSignal: scrollTop[4]).tag(4) } .tint(StrandPalette.accent) + // #1841: the same "Hide bar when scrolling" preference Android drives its own bar with. Here the + // system owns the behaviour — iOS 26's tab bar MINIMISES to a pill on scroll down rather than + // sliding away entirely, so this is the platform's read of the same intent, not a copy of ours. + .noopTabBarAutoHide(bottomBarAutoHide) // Tab crossfade — README §Motion: ~240ms opacity swap between tab roots, global calm // easing cubic-bezier(0.22,1,0.36,1). .animation(.timingCurve(0.22, 1, 0.36, 1, duration: 0.24), value: selectedTab) @@ -172,6 +189,11 @@ struct RootTabView: View { case .insightsHub, .labBook, .fusedRecord, .rhythm: routedPillar = dest router.requestedDestination = nil + case .coach: + // K3: Coach is now a top-level tab (tag 3) — switch to it directly instead of + // presenting it as a pillar sheet. + withAnimation(.timingCurve(0.22, 1, 0.36, 1, duration: 0.24)) { selectedTab = 3 } + router.requestedDestination = nil case .trends: // Trends is a primary tab on iPhone (not a pillar sheet) — switch to it. withAnimation(.timingCurve(0.22, 1, 0.36, 1, duration: 0.24)) { selectedTab = 1 } @@ -187,6 +209,11 @@ struct RootTabView: View { // so a deep-link lands on the Today tab where that entry lives. withAnimation(.timingCurve(0.22, 1, 0.36, 1, duration: 0.24)) { selectedTab = 0 } router.requestedDestination = nil + case .coach: + // #1862: the Today Coach launcher hands its question here. Coach is a pillar sheet on + // iPhone, the same as the Insights hub, so route it that way rather than switching tabs. + routedPillar = dest + router.requestedDestination = nil case .journal: // The #627 Today journal widget opens the journal through the quick-action Journal sheet // (InsightsView), matching the FAB's "Log journal" action. Calm sheet easing. @@ -248,6 +275,8 @@ struct RootTabView: View { case .fusedRecord: FusedRecordHost() case .rhythm: RhythmHost(onClose: { routedPillar = nil }) case .devices: DevicesView() + // K5: the scheduled morning-brief notification's tap-through target. + case .coach: CoachView() // .trends is never presented as a pillar sheet on iPhone (it's a primary tab — the // requestedDestination handler switches `selectedTab` instead), but the switch must stay // exhaustive. Fall back to Trends inside the sheet host if it ever arrives here. @@ -261,6 +290,9 @@ struct RootTabView: View { // .journal opens through the quick-action Journal sheet (handled above); this keeps the // switch exhaustive and falls back to the journal's Insights host if it ever reaches here. case .journal: InsightsView() + // #1862: Coach IS presented here — the launcher sheet routes to it as a pillar, so unlike + // the fallbacks above this arm is the real destination, not a safety net. + case .coach: CoachView() } } // The Trends/Today fallbacks above emit TabRoute value pushes (#198), which need a @@ -387,7 +419,7 @@ struct RootTabView: View { moreSection("Insights") { MoreRow("What Moves You", "wand.and.sparkles", .insightsHub) MoreRow("Intelligence", "brain.head.profile", .intelligence) - MoreRow("Coach", "sparkles", .coach) + // K3: Coach promoted to a top-level tab — no longer listed under More. MoreRow("Insights", "lightbulb.fill", .insights) MoreRow("Explore", "square.grid.2x2.fill", .explore) MoreRow("Compare", "rectangle.split.2x1.fill", .compare) @@ -686,3 +718,25 @@ private struct QuickActionSheet: View { } #endif + +/// #1841: apply the iOS 26 tab-bar minimise behaviour, doing nothing on older systems. +/// +/// The availability branch is deliberately the ONLY branch. `RootTabView` already documents what happens +/// when a condition that flips at runtime wraps this `TabView`: #519 put two states in separate +/// `_ConditionalContent` branches, and every navigation rebuilt the whole subtree, resetting `@State` +/// inside the tab roots — scroll offsets, chart ranges, expanded sections. +/// +/// So the preference must NOT select between branches. It selects the modifier's ARGUMENT, while the +/// availability check — fixed for the life of the process — is what picks a branch. Toggling the setting +/// changes a value, never the view's identity. +extension View { + @ViewBuilder + func noopTabBarAutoHide(_ enabled: Bool) -> some View { + if #available(iOS 26.0, *) { + // `.onScrollDown` minimises to a pill on downward scroll; `.never` pins it fully visible. + self.tabBarMinimizeBehavior(enabled ? .onScrollDown : .never) + } else { + self + } + } +} diff --git a/StrandiOS/App/StrandiOSApp.swift b/StrandiOS/App/StrandiOSApp.swift index 366bc066e0..8f49e8653c 100644 --- a/StrandiOS/App/StrandiOSApp.swift +++ b/StrandiOS/App/StrandiOSApp.swift @@ -23,7 +23,7 @@ struct StrandiOSApp: App { @StateObject private var watch = WatchSessionBridge() /// Shared cross-screen navigation hook (e.g. Live → Devices). The iOS shell (`RootTabView`) /// observes it and presents the Devices manager. - @StateObject private var router = NavRouter() + @StateObject private var router: NavRouter @State private var liveActivity = LiveActivityController() @Environment(\.scenePhase) private var scenePhase /// Appearance preference (System/Light/Dark). Default follows the OS; the Settings picker writes it. @@ -66,6 +66,10 @@ struct StrandiOSApp: App { // is open, so a user testing the wind-down reminder with NOOP foregrounded sees nothing. Register // before the first scene so any early-fired notification is presented. UNUserNotificationCenter.current().delegate = NotificationPresenter.shared + // K5: tapping a scheduled morning-brief notification routes to Coach via the shared NavRouter. + let router = NavRouter() + _router = StateObject(wrappedValue: router) + NotificationPresenter.shared.onCoachBriefTapped = { [weak router] in router?.openCoach() } let model = AppModel() _model = StateObject(wrappedValue: model) // #1538: a strap offload completes while the app is BACKGROUNDED — it stays alive as a @@ -106,6 +110,45 @@ struct StrandiOSApp: App { } } + /// The Shortcut-import alert's presentation binding, hoisted OUT of the `.alert` chain. + /// + /// An inline `Binding(get:set:)` is two untyped closures the solver must infer in place, on a + /// modifier chain that had already blown the type-check budget. Declaring it as a `Binding` + /// property replaces all of that with one known type. Hoisting the message alone was not enough — + /// the build failed again at the same modifier, which is why this one is here too. + private var healthImportAlertPresented: Binding { + Binding( + get: { model.pendingShortcutHealthImport != nil }, + set: { showing in + if !showing { model.cancelPendingHealthImport() } + } + ) + } + + /// The Shortcut-import alert's buttons, hoisted for the same reason as the binding above. + @ViewBuilder + private var healthImportAlertButtons: some View { + Button("Import") { model.confirmPendingHealthImport() } + Button("Cancel", role: .cancel) { model.cancelPendingHealthImport() } + } + + /// The Shortcut-import alert's message, hoisted OUT of the `.alert` chain. + /// + /// Not a style preference. This closure — an `if let` around two interpolated `Text`s — sits on a + /// modifier chain that grew past the Swift type-checker's budget, and the build failed with + /// "unable to type-check this expression in reasonable time" pointing at `} message: {`. The + /// expression did not change; the chain around it did. Hoisting a sub-expression into its own + /// declaration gives the solver a fixed type to work from instead of one more unknown in a chain + /// it is already struggling with. + @ViewBuilder + private var healthImportAlertMessage: some View { + if let pending = model.pendingShortcutHealthImport { + Text("A Shortcut wants to add \(pending.daysCount) days and \(pending.workoutsCount) workouts to the Apple Health import source.") + } else { + Text("A Shortcut wants to add data to the Apple Health import source.") + } + } + var body: some Scene { WindowGroup { iOSRootView() @@ -226,20 +269,10 @@ struct StrandiOSApp: App { model.handleHealthImportURL(url) } } - .alert("Import Apple Health data?", isPresented: Binding( - get: { model.pendingShortcutHealthImport != nil }, - set: { showing in - if !showing { model.cancelPendingHealthImport() } - } - )) { - Button("Import") { model.confirmPendingHealthImport() } - Button("Cancel", role: .cancel) { model.cancelPendingHealthImport() } + .alert("Import Apple Health data?", isPresented: healthImportAlertPresented) { + healthImportAlertButtons } message: { - if let pending = model.pendingShortcutHealthImport { - Text("A Shortcut wants to add \(pending.daysCount) days and \(pending.workoutsCount) workouts to the Apple Health import source.") - } else { - Text("A Shortcut wants to add data to the Apple Health import source.") - } + healthImportAlertMessage } // Bring the watch link up once at launch (WCSession ignores a redundant activate), then // push the first snapshot so a watch that's already on-wrist gets current scores without @@ -260,7 +293,7 @@ struct StrandiOSApp: App { // safe no-op until the user opts in. .onChange(of: scenePhase) { _, phase in if phase == .active { - model.drainPendingIntents() + model.drainPendingIntents(router: router) // Re-arm the strap's smart alarm on foreground: the firmware alarm is a single instant // and iOS can't re-arm it while suspended, so it would otherwise fire once and stop. model.applySmartAlarm() @@ -400,6 +433,16 @@ private struct iOSRootView: View { // Seed the current What's New into the Updates inbox (idempotent per version) so the bell // collects it even if the user dismisses the auto sheet. UpdateStore.shared.seedWhatsNewIfNeeded() + // #1659: iOS cannot auto-update a sideloaded build - no API lets an app install or re-sign an + // .ipa - so the most NOOP can do is NOTICE a release and say so. + // + // Gated on the SAME condition as showWhatsNewIfDue above, and the Android hook. This matters + // now that the check is on by default: without it a brand-new install would reach the network + // during first run, before the Terms gate the user has not accepted yet. While the default was + // off, nothing made that visible. + if onboarded && acceptedTerms == Terms.currentVersion { + UpdateWatch.runIfDue(currentVersion: UpdateWatch.installedVersion, sideloadHint: true) + } } .onChange(of: acceptedTerms) { _, _ in showWhatsNewIfDue() } } diff --git a/StrandiOS/Health/HealthKitBridge.swift b/StrandiOS/Health/HealthKitBridge.swift index de533e31e5..d251578554 100644 --- a/StrandiOS/Health/HealthKitBridge.swift +++ b/StrandiOS/Health/HealthKitBridge.swift @@ -1,6 +1,7 @@ #if os(iOS) import Foundation import HealthKit +import CoreLocation import UIKit import WhoopStore import StrandAnalytics @@ -94,6 +95,11 @@ final class HealthKitBridge: ObservableObject { for id in HealthKitBridge.quantityReadIds { if let t = HKObjectType.quantityType(forIdentifier: id) { s.insert(t) } } if let sleep = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) { s.insert(sleep) } s.insert(HKObjectType.workoutType()) + // #1205: workout routes (GPS) so imported workouts can show their map. Routes are separate + // samples associated with each HKWorkout; without this in readTypes the route query returns + // empty and the imported workout shows distance but no map — same as today, so the addition + // is strictly additive. + s.insert(HKSeriesType.workoutRoute()) return s } @@ -768,6 +774,14 @@ final class HealthKitBridge: ObservableObject { func attempt(_ op: () async throws -> Void) async { do { try await op() } catch { if firstError == nil { firstError = error } } } + // #1503: one-off sweep to clear records stranded under the OLD device-id-keyed scheme. + // The old keys embedded the active strap id (`noop:::`), which + // became unreachable after a re-pair. The new keys drop the id segment, so the normal + // delete-then-write can never find the old records. This sweep deletes ALL of our prior + // records in the write-back window by `HKSource.default()` + date range (the same pattern + // the HR path uses), then the normal writes re-add them under the new keys. Runs once, + // gated by a UserDefaults flag, BEFORE the new-key writes so nothing is lost. + await attempt { try await migrateStrandedHealthRecords(fromTs: fromTs, nowTs: nowTs) } await attempt { try await writeVitals(whoopStore: whoopStore, days: days, sessions: sessions) } await attempt { try await writeSleep(sessions: sessions) } await attempt { try await writeHeartRate(whoopStore: whoopStore, fromTs: fromTs, nowTs: nowTs) } @@ -775,6 +789,75 @@ final class HealthKitBridge: ObservableObject { if let firstError { throw firstError } } + /// UserDefaults key for the #1503 stranded-records sweep completion set. Stores the set of + /// type ids whose sweep delete has SUCCEEDED, so a type that was unauthorized or whose delete + /// failed is retried on a later write-back run rather than abandoned. Per-type (not a single + /// boolean) because partial authorization is a supported state: a user who granted sleep but + /// declined workouts must still get the stranded workouts swept once they later grant workouts, + /// and a `deleteObjects` that threw must not read as "already done". + private static let strandedRecordsSweptKey = "hkStrandedRecordsSweptTypes.v1" + + /// Stable type ids used in the per-type swept set. The quantity identifiers are the + /// `HKQuantityTypeIdentifier` raw values; sleep and workout use fixed labels so the set is + /// round-trippable through UserDefaults without depending on HealthKit type object identity. + private static let sleepSweepTypeId = "sleepAnalysis" + private static let workoutSweepTypeId = "workout" + + /// One-off migration (#1503): delete ALL of our prior Apple Health records in the write-back + /// window that were written under the old device-id-keyed scheme. The old keys embedded the + /// active strap id, which became unreachable after a re-pair; the new keys drop the id segment. + /// This sweep uses `HKSource.default()` + date range (like the HR path) to reach records the + /// new key-based delete predicate can never find. Runs BEFORE the new-key writes so the fresh + /// batch re-adds everything under the stable keys. + /// + /// Completion is tracked PER-TYPE (not a single boolean): a type is marked swept only when its + /// `deleteObjects` call returned a result (succeeded, even if it deleted zero records). A type + /// that was unauthorized or whose delete threw is left un-swept, so a later authorization grant + /// or a successful retry finishes the migration instead of abandoning the stranded records. + /// The decision helpers live in `HealthWriteback` so they are unit-tested without HealthKit. + private func migrateStrandedHealthRecords(fromTs: Int, nowTs: Int) async throws { + let defaults = UserDefaults.standard + let swept: Set = Set(defaults.stringArray(forKey: Self.strandedRecordsSweptKey) ?? []) + let bySource = HKQuery.predicateForObjects(from: HKSource.default()) + let byDate = HKQuery.predicateForSamples( + withStart: Date(timeIntervalSince1970: TimeInterval(fromTs)), + end: Date(timeIntervalSince1970: TimeInterval(nowTs) + 60), + options: []) + let pred = NSCompoundPredicate(andPredicateWithSubpredicates: [bySource, byDate]) + var succeededThisRun: Set = [] + // Vitals: each quantity type that carried an id-keyed external UUID. + for id in Self.quantityWriteIds { + let typeId = id.rawValue + guard !swept.contains(typeId), + let type = HKQuantityType.quantityType(forIdentifier: id), + store.authorizationStatus(for: type) == .sharingAuthorized else { continue } + // `try?` returns nil on throw — a failure is NOT recorded as swept, so it retries next run. + if (try? await store.deleteObjects(of: type, predicate: pred)) != nil { + succeededThisRun.insert(typeId) + } + } + // Sleep. + if !swept.contains(Self.sleepSweepTypeId), + let sleep = HKObjectType.categoryType(forIdentifier: .sleepAnalysis), + store.authorizationStatus(for: sleep) == .sharingAuthorized { + if (try? await store.deleteObjects(of: sleep, predicate: pred)) != nil { + succeededThisRun.insert(Self.sleepSweepTypeId) + } + } + // Workouts. + if !swept.contains(Self.workoutSweepTypeId), + store.authorizationStatus(for: .workoutType()) == .sharingAuthorized { + if (try? await store.deleteObjects(of: .workoutType(), predicate: pred)) != nil { + succeededThisRun.insert(Self.workoutSweepTypeId) + } + } + // Persist only the types that actually succeeded this run; the rest stay pending. + if !succeededThisRun.isEmpty { + let updated = HealthWriteback.strandedSweepResult(swept: swept, succeededThisRun: succeededThisRun) + defaults.set(Array(updated), forKey: Self.strandedRecordsSweptKey) + } + } + /// The nightly vitals write (the original write-back), now stamped at the day's wake time when /// that day has a sleep session — a real timestamp inside the night the value describes, instead /// of a fabricated noon. Keys are unchanged, so re-stamped samples replace their noon ancestors. @@ -807,7 +890,11 @@ final class HealthKitBridge: ObservableObject { func add(_ id: HKQuantityTypeIdentifier, _ unit: HKUnit, _ value: Double, _ day: String, _ at: Date) { guard let type = HKQuantityType.quantityType(forIdentifier: id), store.authorizationStatus(for: type) == .sharingAuthorized else { return } - let key = "noop:\(noopDeviceId):\(id.rawValue):\(day)" + // #1503: the key carries NO device-id segment. The previous scheme embedded the active + // strap id, which is not durable — a re-pair changed it and stranded every prior record + // in Apple Health as unreachable duplicates. The natural key (`noop::`) + // matches the Android twin and is stable across strap lifecycle changes. + let key = HealthWriteback.appleHealthVitalKey(metricId: id.rawValue, day: day) let sample = HKQuantitySample( type: type, quantity: .init(unit: unit, doubleValue: value), @@ -870,10 +957,12 @@ final class HealthKitBridge: ObservableObject { /// `.asleepUnspecified` block instead of fabricated stage placement. /// /// Dedup: every sample of a night carries `HKMetadataKeyExternalUUID = - /// noop::sleep:` keyed by the group's EARLIEST fragment's immutable detected + /// noop:sleep:` keyed by the group's EARLIEST fragment's immutable detected /// onset (a user edit moves the span, never the key). The delete predicate carries EVERY /// fragment's key, so a night previously written as two entries fully clears when it becomes - /// one; delete-then-write scoped to our own `HKSource`, like the vitals. + /// one; delete-then-write scoped to our own `HKSource`, like the vitals. The key carries NO + /// device-id segment (#1503): the active strap id is not durable, and embedding it stranded + /// every prior record as unreachable duplicates after a re-pair. private func writeSleep(sessions: [CachedSleepSession]) async throws { guard let type = HKObjectType.categoryType(forIdentifier: .sleepAnalysis), store.authorizationStatus(for: type) == .sharingAuthorized else { return } @@ -889,9 +978,9 @@ final class HealthKitBridge: ObservableObject { var samples: [HKCategorySample] = [] var keys: [String] = [] for entry in HealthWriteback.mergedSleepPlan(groups: groups) { - let key = "noop:\(noopDeviceId):sleep:\(entry.keyStartTs)" + let key = HealthWriteback.appleHealthSleepKey(startTs: entry.keyStartTs) let meta = [HKMetadataKeyExternalUUID: key] - keys.append(contentsOf: entry.allKeyStartTs.map { "noop:\(noopDeviceId):sleep:\($0)" }) + keys.append(contentsOf: entry.allKeyStartTs.map { HealthWriteback.appleHealthSleepKey(startTs: $0) }) samples.append(HKCategorySample(type: type, value: HKCategoryValueSleepAnalysis.inBed.rawValue, start: Date(timeIntervalSince1970: TimeInterval(entry.spanStart)), end: Date(timeIntervalSince1970: TimeInterval(entry.spanEnd)), @@ -985,8 +1074,11 @@ final class HealthKitBridge: ObservableObject { /// sports. Workouts whose source is `apple-health` are EXCLUDED — those were imported FROM /// Health, and writing them back would duplicate the user's own Apple Watch/gym-app workouts. /// - /// Dedup: `HKMetadataKeyExternalUUID = noop::workout:` in the workout - /// metadata; delete-then-write scoped to our own source, like sleep and the vitals. + /// Dedup: `HKMetadataKeyExternalUUID = noop:workout:` in the workout + /// metadata; delete-then-write scoped to our own source, like sleep and the vitals. The key + /// carries NO device-id segment (#1503): the active strap id is not durable, and embedding it + /// stranded every prior workout as unreachable duplicates after a re-pair. Matches the Android + /// twin's `noop-workout-` `clientRecordId`. private func writeWorkouts(whoopStore: WhoopStore, fromTs: Int, toTs: Int) async throws { guard store.authorizationStatus(for: .workoutType()) == .sharingAuthorized else { return } let mine = (try? await whoopStore.workouts(deviceId: noopDeviceId, from: fromTs, to: toTs, limit: 500)) ?? [] @@ -998,7 +1090,7 @@ final class HealthKitBridge: ObservableObject { let rows = byKey.values.sorted { $0.startTs < $1.startTs } guard !rows.isEmpty else { return } - func key(_ row: WorkoutRow) -> String { "noop:\(noopDeviceId):workout:\(row.startTs)" } + func key(_ row: WorkoutRow) -> String { HealthWriteback.appleHealthWorkoutKey(startTs: row.startTs) } let pred = NSCompoundPredicate(andPredicateWithSubpredicates: [ HKQuery.predicateForObjects(from: HKSource.default()), HKQuery.predicateForObjects(withMetadataKey: HKMetadataKeyExternalUUID, @@ -1293,16 +1385,21 @@ final class HealthKitBridge: ObservableObject { HKQuery.predicateForSamples(withStart: start, end: end, options: .strictStartDate), Self.notNoopAuthored, ]) - return await withCheckedContinuation { (cont: CheckedContinuation<[WorkoutRow], Never>) in + // #1205: collect the HKWorkout objects alongside the rows so we can fetch their GPS routes + // after the sample query completes. Routes are separate HKWorkoutRoute samples associated + // with each workout; they cannot be read inside the sample query's completion handler + // (HealthKit does not allow nested queries on the same store), so we hold the workouts and + // fetch routes in a second pass below. + let workoutsAndRows: [(HKWorkout, WorkoutRow)] = await withCheckedContinuation { (cont: CheckedContinuation<[(HKWorkout, WorkoutRow)], Never>) in let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true) let q = HKSampleQuery(sampleType: HKObjectType.workoutType(), predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: [sort]) { _, samples, _ in - var rows: [WorkoutRow] = [] + var pairs: [(HKWorkout, WorkoutRow)] = [] for case let workout as HKWorkout in samples ?? [] { let startTs = Int(workout.startDate.timeIntervalSince1970) let endTs = max(Int(workout.endDate.timeIntervalSince1970), startTs) let duration = workout.duration > 0 ? workout.duration : Double(endTs - startTs) - rows.append(WorkoutRow( + pairs.append((workout, WorkoutRow( startTs: startTs, endTs: endTs, sport: Self.sportName(workout.workoutActivityType), @@ -1314,12 +1411,67 @@ final class HealthKitBridge: ObservableObject { strain: nil, distanceM: workout.totalDistance?.doubleValue(for: .meter()), zonesJSON: nil, - notes: nil, steps: nil)) + notes: nil, steps: nil))) } - cont.resume(returning: rows) + cont.resume(returning: pairs) } store.execute(q) } + // #1205: fetch and store GPS routes for each workout. Best-effort — a route read failure + // (permission not granted, no route data, HealthKit error) leaves the workout intact with + // no map, which is exactly the pre-change behaviour. Stored via RouteStore under the same + // (startTs, sport) key WorkoutDetailView loads from, so no UI change is needed. + // Collected first and written ONCE. `RouteStore.store` rewrites the entire capped map on every + // call, which is right for the recorder's single call at workout end but quadratic here: a + // 500-workout first import would decode and re-encode a 400-entry map 500 times, on the order of + // a gigabyte of JSON through UserDefaults. `storeAll` applies the same eviction, once. + var importedRoutes: [(route: WorkoutRoute, startTs: Int, sport: String)] = [] + for (workout, row) in workoutsAndRows { + if let route = await Self.fetchWorkoutRoute(for: workout, store: store), + route.count >= 2 { + let polyline = RouteMath.encode(route) + let distanceM = RouteMath.totalMeters(route) + importedRoutes.append((WorkoutRoute(polyline: polyline, distanceM: distanceM), + row.startTs, row.sport)) + } + } + RouteStore.storeAll(importedRoutes) + return workoutsAndRows.map { $0.1 } + } + + /// #1205: fetch the GPS route (list of `RouteMath.LatLng`) for a single `HKWorkout`. + /// Queries `HKWorkoutRoute` samples overlapping the workout's time range, then collects all + /// `CLLocation` waypoints from each route via `HKWorkoutRouteQuery`. Returns `nil` when there + /// is no route, the user has not granted route read access, or HealthKit reports an error — + /// all of which are graceful skips (the workout imports without a map, same as today). + nonisolated static func fetchWorkoutRoute(for workout: HKWorkout, store: HKHealthStore) async -> [RouteMath.LatLng]? { + let routeType = HKSeriesType.workoutRoute() + let predicate = HKQuery.predicateForSamples(withStart: workout.startDate, end: workout.endDate, options: .strictStartDate) + // First: query for HKWorkoutRoute samples associated with this workout. + let routes: [HKWorkoutRoute] = await withCheckedContinuation { (cont: CheckedContinuation<[HKWorkoutRoute], Never>) in + let q = HKSampleQuery(sampleType: routeType, predicate: predicate, + limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, _ in + let routeSamples = (samples as? [HKWorkoutRoute]) ?? [] + cont.resume(returning: routeSamples) + } + store.execute(q) + } + guard !routes.isEmpty else { return nil } + // Second: collect CLLocation waypoints from each route. HKWorkoutRouteQuery calls its + // handler repeatedly with batches of locations; `done: true` marks the end of one route. + var points: [RouteMath.LatLng] = [] + for route in routes { + await withCheckedContinuation { (cont: CheckedContinuation) in + let query = HKWorkoutRouteQuery(route: route) { _, locations, done, _ in + for loc in locations ?? [] { + points.append(RouteMath.LatLng(loc.coordinate.latitude, loc.coordinate.longitude)) + } + if done { cont.resume(returning: ()) } + } + store.execute(query) + } + } + return points.isEmpty ? nil : points } /// Source tag stamped on workouts imported from Apple Health. Matches the macOS importer's diff --git a/StrandiOS/System/HomeScreenQuickActions.swift b/StrandiOS/System/HomeScreenQuickActions.swift index b183d3369a..42c08a3fa7 100644 --- a/StrandiOS/System/HomeScreenQuickActions.swift +++ b/StrandiOS/System/HomeScreenQuickActions.swift @@ -1,4 +1,5 @@ #if os(iOS) +import StrandAnalytics import SwiftUI import UIKit @@ -82,6 +83,67 @@ final class HomeScreenQuickActionAppDelegate: NSObject, UIApplicationDelegate { /// automatically places an observable scene delegate in the environment for the scene it manages. @MainActor final class HomeScreenQuickActionSceneDelegate: NSObject, UIWindowSceneDelegate, ObservableObject { + // MARK: - Standard-HR persistence at the lifecycle edges (#1770) + // + // On the SCENE delegate, not the app delegate, and not on `StrandiOSApp.body`. Both placements are + // load-bearing and both were arrived at the hard way. + // + // Not the body: the same two edges were added there in #1767 and took the iOS build down. `body` is a + // SINGLE expression carrying ~28 chained modifiers; two more tipped it past the type-checker's budget, + // and the error was reported at a modifier a hundred lines from the change. The cost was the + // expression's length, not any leaf of it. + // + // Not the app delegate: this app is scene-based — it returns a UISceneConfiguration from + // `application(_:configurationForConnecting:options:)` — and UIKit does not call + // applicationDidEnterBackground or applicationWillTerminate on the app delegate of a scene-based app. + // Those methods would have compiled, shipped, and silently never run, which is a worse failure than a + // red build because nothing reports it. + + /// Give buffered standard-HR rows a real persistence attempt as the scene leaves the foreground. + /// + /// iOS suspends a connected strap WITHOUT a disconnect edge, and the Collector's 30-sample / + /// 30-second cadence timer does not run while suspended — so a sub-cadence 0x2A37 batch sits in + /// memory and dies with the app if iOS terminates it before it resumes. + func sceneDidEnterBackground(_ scene: UIScene) { + // A bare Task is suspended along with the app, which would leave this doing nothing at the one + // moment it exists for. Ask UIKit for a window and end it on BOTH paths, so the assertion is + // always released rather than expiring. + let application = UIApplication.shared + var taskID: UIBackgroundTaskIdentifier = .invalid + taskID = application.beginBackgroundTask(withName: "standard-hr-lifecycle-flush") { + application.endBackgroundTask(taskID) + taskID = .invalid + } + // @MainActor explicitly: AppModel and BLEManager are both main-actor isolated, and a bare Task + // from a nonisolated delegate callback does not inherit that. Same idiom BLEManager uses for this + // exact call on the disconnect edge. + Task { @MainActor in + await Self.flushStandardHR(.background) + if taskID != .invalid { + application.endBackgroundTask(taskID) + taskID = .invalid + } + } + } + + /// The final retry edge, for a scene the system is discarding. Best effort BY CONSTRUCTION: there is + /// no background window here and little time, so this is not a guarantee and must not be described as + /// one. Usually `sceneDidEnterBackground` has already drained the buffer and an empty Collector is a + /// cheap no-op. + func sceneDidDisconnect(_ scene: UIScene) { + Task { @MainActor in await Self.flushStandardHR(.termination) } + } + + /// Reaches the live Collector through `AppModel.shared`, the seam App Intents already use. It is + /// `weak`, so a nil here means no model is alive and there is nothing buffered to lose. + @MainActor + private static func flushStandardHR(_ event: StandardHRLifecycleFlush.Event) async { + guard let ble = AppModel.shared?.ble else { return } + await StandardHRLifecycleFlush.run(event: event) { reason in + await ble.flushStandardHRForLifecycle(reason: reason) + } + } + @Published private(set) var pendingAction: HomeScreenQuickAction? func scene( diff --git a/StrandiOS/System/NOOPAppIntents.swift b/StrandiOS/System/NOOPAppIntents.swift index ce0e847380..b14ec61f90 100644 --- a/StrandiOS/System/NOOPAppIntents.swift +++ b/StrandiOS/System/NOOPAppIntents.swift @@ -6,9 +6,12 @@ import AppIntents /// into the running `AppModel` directly (BLE only lives in the foreground app), so they enqueue here /// and the app drains the queue when it next becomes active. enum PendingIntents { - enum Action: String { case markMoment, buzz } + enum Action: String { case markMoment, buzz, askCoach } private static let key = "noop.pendingIntents" + /// K9: the question text for a pending `.askCoach` action. Stored separately because the + /// action queue encodes as `[String]` and a question can contain colons. + private static let coachQuestionKey = "noop.pendingCoachQuestion" private static var defaults: UserDefaults? { UserDefaults(suiteName: WidgetSnapshot.suiteName) } /// Optional `at` is the invocation time, captured now and consumed on drain. Encoded into the @@ -22,6 +25,23 @@ enum PendingIntents { d.set(list, forKey: key) } + /// K9: queue an "Ask Coach" action with the associated question text. The question is stored + /// in a dedicated key (one pending question at a time — the user rarely queues multiple Siri + /// questions before the app opens). + static func appendAskCoach(question: String, at date: Date? = nil) { + guard let d = defaults else { return } + d.set(question, forKey: coachQuestionKey) + append(.askCoach, at: date) + } + + /// K9: read and clear the pending coach question. Returns nil when no question is queued. + static func consumeCoachQuestion() -> String? { + guard let d = defaults else { return nil } + let q = d.string(forKey: coachQuestionKey) + d.removeObject(forKey: coachQuestionKey) + return q + } + static func drain() -> [(action: Action, date: Date?)] { guard let d = defaults else { return [] } let raw = d.stringArray(forKey: key) ?? [] @@ -62,6 +82,24 @@ struct BuzzStrapIntent: AppIntent { } } +/// K9: Ask the Coach a question via Siri. Queues the question and opens the app, which sends it +/// to the configured provider and surfaces the response. The question is spoken or typed in Siri; +/// the app handles the actual network call using the user's saved key. +struct AskCoachIntent: AppIntent { + static var title: LocalizedStringResource = "Ask Coach" + static var description = IntentDescription("Ask your NOOP Coach a question about your recovery, sleep, or training.") + static var openAppWhenRun = true + + /// The question to ask, populated by Siri from the user's spoken phrase. + @Parameter(title: "Question") + var question: String + + func perform() async throws -> some IntentResult & ProvidesDialog { + PendingIntents.appendAskCoach(question: question, at: Date()) + return .result(dialog: "Opening Coach with your question: \(question)") + } +} + /// Surfaces NOOP's intents to Siri, Spotlight, and the Shortcuts gallery without any user setup. struct NOOPShortcuts: AppShortcutsProvider { static var appShortcuts: [AppShortcut] { @@ -73,6 +111,16 @@ struct NOOPShortcuts: AppShortcutsProvider { phrases: ["Buzz my \(.applicationName) strap"], shortTitle: "Buzz Strap", systemImageName: "waveform.path") + // K9: "Ask Coach" via Siri — opens Coach with the question and sends it. The question + // parameter is provided via the Shortcuts app or Siri prompts for it when the phrase fires. + AppShortcut(intent: AskCoachIntent(), + phrases: [ + "Ask \(.applicationName) about my recovery", + "Ask \(.applicationName) how I'm doing", + "Ask \(.applicationName) Coach", + ], + shortTitle: "Ask Coach", + systemImageName: "sparkles") } } #endif diff --git a/StrandiOS/Widgets/WidgetPublish.swift b/StrandiOS/Widgets/WidgetPublish.swift index 2f75feb1fb..f0ad2099d2 100644 --- a/StrandiOS/Widgets/WidgetPublish.swift +++ b/StrandiOS/Widgets/WidgetPublish.swift @@ -21,6 +21,7 @@ extension WidgetSnapshot { /// the rollover yet always describes today. @MainActor static func publish(from model: AppModel) async { + await refreshWidgetPresence() let days = model.repo.days let now = Date() // The recovery-derived anchor: today's row when it's scored, else the freshest STRICTLY-PRIOR @@ -110,15 +111,59 @@ extension WidgetSnapshot { private static func saveAndReloadIfChanged(_ snap: WidgetSnapshot, previous: WidgetSnapshot? = nil) { let previous = previous ?? load() if renderedContentChanged(from: previous, to: snap) { - snap.save() + snap.save(previousSeries: previous?.hrSeries ?? []) WidgetCenter.shared.reloadAllTimelines() + // Android skips the update entirely when no widget is placed; WidgetKit offers no + // synchronous way to know, so the reload still goes out and is instead recorded honestly. + // Counting it as a reload would make a widget-removed export read exactly like a + // widget-installed one, which is half the comparison the counters exist for. + if WidgetTelemetry.widgetsInstalled { + WidgetTelemetry.noteReloaded() + } else { + WidgetTelemetry.noteNoWidget() + } + } else if WidgetSnapshot.traceNeedsPoint(previous: previous, bpm: snap.bpm, now: snap.updated) { + // A steady heart changes nothing the header renders, so the branch above declines — but the + // TRACE still wants this minute's point, or it stops advancing at rest and prunes to empty + // (#1957). Persist without a reload: the point is for the next timeline WidgetKit builds, + // and spending a reload a minute is exactly what the dedup above exists to avoid. + snap.save(previousSeries: previous?.hrSeries ?? []) + WidgetTelemetry.noteDeclined() } else if liveUpdateRequiresFullBuild(previous: previous, now: snap.updated) { // The rollover's visible values can legitimately match yesterday's. Persist the fresh day // stamp once without spending a redundant WidgetKit reload, so later live ticks stay fast. - snap.save() + snap.save(previousSeries: previous?.hrSeries ?? []) + WidgetTelemetry.noteDeclined() + } else { + // Nothing at all to do. Counted rather than left as a silent fall-through: an outcome that + // records nothing is exactly how the Android counters came to report publishes that never + // went anywhere as if they had. + WidgetTelemetry.noteDeclined() } } + /// Ask WidgetKit whether any widget is actually installed, and remember the answer. + /// + /// Only on the full publish path: it is already `async`, and the once-a-minute live path has no + /// `await` to spend on an XPC round trip it does not need. The answer changes when a user adds or + /// removes a widget, which is exactly when the app is being foregrounded anyway, so a value from + /// the last full publish is fresh enough for a diagnostic. + /// + /// A failure leaves the previous answer in place rather than guessing, and "never asked" counts as + /// installed — over-reporting reloads is the safe direction for a figure meant to show a cost. + @MainActor + private static func refreshWidgetPresence() async { + let installed: Bool? = await withCheckedContinuation { continuation in + WidgetCenter.shared.getCurrentConfigurations { result in + switch result { + case .success(let widgets): continuation.resume(returning: !widgets.isEmpty) + case .failure: continuation.resume(returning: nil) + } + } + } + if let installed { WidgetTelemetry.noteWidgetsInstalled(installed) } + } + /// #114/#169: HR is the ONE high-frequency widget-publish trigger — `model.bpm` moves every few /// seconds during activity, unlike battery (~8 min) or connection flips (rare). Left ungated, the /// `model.$bpm` hook rewrote the shared snapshot + called `reloadAllTimelines()` on every tick (and, @@ -133,8 +178,12 @@ extension WidgetSnapshot { /// True (and stamps `now`) when at least `interval` has elapsed since the last HR-driven publish; /// false to skip this HR change. The first call always admits (`.distantPast`). static func admit(now: Date = Date()) -> Bool { - guard now.timeIntervalSince(lastPublishedAt) >= interval else { return false } + guard now.timeIntervalSince(lastPublishedAt) >= interval else { + WidgetTelemetry.noteGated(now: now) + return false + } lastPublishedAt = now + WidgetTelemetry.noteAdmitted(now: now) return true } } diff --git a/StrandiOSShared/HrTrace.swift b/StrandiOSShared/HrTrace.swift new file mode 100644 index 0000000000..b53b929538 --- /dev/null +++ b/StrandiOSShared/HrTrace.swift @@ -0,0 +1,169 @@ +import Foundation + +/// One point on the widget's heart-rate trace: when it was taken, and the bpm. +/// +/// `Sendable` because a SwiftUI `Shape` holding these is itself Sendable, and the compiler warns that +/// carrying a non-Sendable stored property there is an error in a future language mode. Two immutable +/// value fields, so it is Sendable by inspection rather than by assertion. +public struct HrPoint: Codable, Equatable, Sendable { + public let ts: Int64 + public let bpm: Int + public init(ts: Int64, bpm: Int) { + self.ts = ts + self.bpm = bpm + } +} + +/// The pure half of the heart-rate widget: what the trace CONTAINS and where its ink goes. +/// +/// Swift twin of the Kotlin `HrTrace`. The retention rule, the tick choices and the normalisation are +/// the same decisions on both platforms and must not be made twice — a trace that kept a different +/// window, or picked different labels, would be a different reading of the same heart. +/// +/// What deliberately does NOT port is the bitmap sizing: `fitBox`, the byte budget and the 565 pixel +/// depth exist because Glance compiles to RemoteViews, cannot draw, and carries its payload over a +/// Binder transaction with a hard ceiling. WidgetKit is SwiftUI and strokes the trace as a vector, so +/// there is no bitmap to budget for and nothing here needs a pixel format. +/// +/// `encode`/`decode` do not port either. Android packs the series into a SharedPreferences string +/// because that is what it has; the snapshot here is already Codable and carries `[HrPoint]` directly, +/// so a hand-rolled format would be a second encoding to keep correct for no gain. +public enum HrTrace { + + /// How much history the trace shows. Three hours matches what fits legibly at widget width. + public static let windowSec: Int64 = 3 * 60 * 60 + + /// One point per minute. The strap streams ~1/s; keeping every sample would put 10 800 points + /// behind a trace a few hundred points wide, which costs space and buys nothing the eye resolves. + public static let bucketSec: Int64 = 60 + + /// Hard cap, so a clock jump backwards cannot grow the series without bound. + public static let maxPoints = 200 + + /// Fold a fresh reading into the series: one point per minute bucket, newest wins within a bucket. + /// + /// Newest-wins rather than an average because the widget's headline number is the LATEST bpm, and a + /// trace whose last point disagreed with the number printed above it would read as a bug. + public static func append(_ series: [HrPoint], ts: Int64, bpm: Int, nowSec: Int64? = nil) -> [HrPoint] { + let now = nowSec ?? ts + guard bpm > 0 else { return prune(series, nowSec: now) } + let bucket = ts / bucketSec * bucketSec + var out = series.filter { $0.ts != bucket } + out.append(HrPoint(ts: bucket, bpm: bpm)) + // No sort here: `prune` sorts on the way out and has to anyway, since it is also the entry point + // for a decoded series. Appending at the end is already in order for anything but a backwards + // clock, and prune's sort is what makes even that correct. + return prune(out, nowSec: now) + } + + /// Drop anything older than the window, then anything beyond the cap (oldest first). + public static func prune(_ series: [HrPoint], nowSec: Int64) -> [HrPoint] { + let floor = nowSec - windowSec + let kept = series.filter { $0.ts >= floor }.sorted { $0.ts < $1.ts } + guard kept.count > maxPoints else { return kept } + return Array(kept.suffix(maxPoints)) + } + + /// The three numbers the header shows. Nil when there is nothing to describe. + public struct Stats: Equatable { + public let min: Int + public let max: Int + public let latest: Int + } + + public static func stats(_ series: [HrPoint]) -> Stats? { + guard let first = series.first, let last = series.last else { return nil } + var lo = first.bpm + var hi = first.bpm + for p in series { + if p.bpm < lo { lo = p.bpm } + if p.bpm > hi { hi = p.bpm } + } + return Stats(min: lo, max: hi, latest: last.bpm) + } + + /// A point in the trace's drawing box, origin top-left. + public struct Pt: Equatable { + public let x: CGFloat + public let y: CGFloat + } + + /// The trace as coordinates inside a `width` x `height` box. + /// + /// Two degenerate shapes decide most of this, and both are ordinary rather than exotic — a widget + /// placed mid-afternoon has one point, and a resting arm holds one bpm for minutes at a time: + /// + /// - ONE point, or every point at the same instant: there is no time axis to spread across, so it + /// sits at the left edge rather than at x = NaN. + /// - every bpm equal: there is no range to scale into, so the line runs along the VERTICAL MIDDLE. + /// Pinning it to the top or bottom would read as a maxed-out or flatlined heart. + /// + /// Y is flipped on the way out: bpm rises upward, screen coordinates rise downward. + public static func points(_ series: [HrPoint], width: CGFloat, height: CGFloat) -> [Pt] { + guard !series.isEmpty, width > 0, height > 0, + let t0 = series.first?.ts, let t1 = series.last?.ts else { return [] } + let span = CGFloat(t1 - t0) + var lo = series[0].bpm + var hi = series[0].bpm + for p in series { + if p.bpm < lo { lo = p.bpm } + if p.bpm > hi { hi = p.bpm } + } + let range = CGFloat(hi - lo) + return series.map { p in + let x = span <= 0 ? 0 : CGFloat(p.ts - t0) / span * width + let y = range <= 0 ? height / 2 : height - CGFloat(p.bpm - lo) / range * height + return Pt(x: x, y: y) + } + } + + /// The three bpm labels down the right edge: max, midpoint, min, stacked as they appear. + /// + /// A flat series returns the same number three times rather than an invented spread — the honest + /// reading of a steady heart is one number. The VIEW declines to draw a scale in that case; the + /// rule stays here so both platforms agree on what the labels would be. + public static func bpmTicks(_ stats: Stats) -> [Int] { + [stats.max, (stats.min + stats.max + 1) / 2, stats.min] + } + + /// The three timestamps along the bottom: first, middle, last of the DATA, not of the window. + /// + /// Anchored to the data because a widget holding forty minutes of history would otherwise label its + /// axis with two hours that were never sampled. Fewer than three distinct instants returns what + /// there is, so the view draws one label rather than three copies of it. + public static func timeTicks(_ series: [HrPoint]) -> [Int64] { + guard let first = series.first?.ts, let last = series.last?.ts else { return [] } + if first == last { return [first] } + let mid = first + (last - first) / 2 + if mid == first || mid == last { return [first, last] } + return [first, mid, last] + } +} + +/// What heart rate the widget should SHOW, given how long ago it was actually measured. +/// +/// Twin of the Kotlin `HrDisplay`, and the reason it exists is the same on both platforms: a snapshot +/// carries the last reading indefinitely, so without an age rule a widget renders an hours-old number as +/// though it were current. +/// +/// Anchored to the newest TRACE POINT rather than to the snapshot's `updated`, which is the closer twin +/// of Android reading its own `hrAt`. A publish happens for battery or score changes too, so `updated` +/// refreshes without a new reading and would make a stale heart look fresh. +public enum HrDisplay { + /// Past this without a newer reading the number is DIMMED: still shown, no longer claimed as live. + public static let liveWindow: TimeInterval = 2 * 60 + + /// Past this it is dropped entirely — too old to stand for the wearer at all. + public static let staleCap: TimeInterval = 15 * 60 + + /// - Returns: the bpm to show (nil to show nothing) and whether it is a carried-over reading. + public static func resolve(bpm: Int?, newestPointTs: Int64?, now: Date) -> (bpm: Int?, stale: Bool) { + guard let bpm, bpm > 0 else { return (nil, false) } + // No trace yet is the FIRST reading, not an old one: a widget added this minute has a bpm and an + // empty series, and dropping the number there would blank a widget that just started working. + guard let newestPointTs else { return (bpm, false) } + let age = now.timeIntervalSince1970 - TimeInterval(newestPointTs) + if age > staleCap { return (nil, false) } + return (bpm, age > liveWindow) + } +} diff --git a/StrandiOSShared/WidgetSnapshot.swift b/StrandiOSShared/WidgetSnapshot.swift index fb7e5c6f5e..34075848d0 100644 --- a/StrandiOSShared/WidgetSnapshot.swift +++ b/StrandiOSShared/WidgetSnapshot.swift @@ -21,10 +21,17 @@ public struct WidgetSnapshot: Codable, Equatable { public var effortDisplay: String? /// True when `effortDisplay` is on WHOOP's 0–21 axis; false/nil means 0–100. Accessibility only. public var effortWhoop: Bool? + /// The last `HrTrace.windowSec` of heart rate, one point per minute, for the trace widget (#1957). + /// + /// Folded in by `save()` rather than by the callers that build a snapshot, which is the twin of the + /// Android store owning it: nothing that publishes had to learn the retention rule. Optional so a + /// snapshot written by an older build still decodes. + public var hrSeries: [HrPoint]? public init(recovery: Int?, bpm: Int?, batteryPct: Int?, bonded: Bool, updated: Date, effort: Int? = nil, rest: Int? = nil, hrv: Int? = nil, restingHr: Int? = nil, - effortDisplay: String? = nil, effortWhoop: Bool? = nil) { + effortDisplay: String? = nil, effortWhoop: Bool? = nil, + hrSeries: [HrPoint]? = nil) { self.recovery = recovery self.bpm = bpm self.batteryPct = batteryPct @@ -36,6 +43,7 @@ public struct WidgetSnapshot: Codable, Equatable { self.restingHr = restingHr self.effortDisplay = effortDisplay self.effortWhoop = effortWhoop + self.hrSeries = hrSeries } /// App Group suite the app and widget both use. Injected from the `APP_GROUP_ID` build setting @@ -113,13 +121,47 @@ public struct WidgetSnapshot: Codable, Equatable { return snap } - /// Persist this snapshot into the shared suite. + /// Persist this snapshot into the shared suite, folding the live bpm into the trace on the way. + /// + /// The fold happens HERE, not in the callers that build a snapshot, so nothing that publishes has to + /// know the retention rule — the twin of the Android store owning it. A snapshot with no bpm leaves + /// the stored trace alone rather than truncating it, so a quiet strap does not erase the history the + /// widget is drawing. public func save() { - guard let defaults = UserDefaults(suiteName: WidgetSnapshot.suiteName), - let data = try? JSONEncoder().encode(self) else { return } + save(previousSeries: WidgetSnapshot.load()?.hrSeries ?? []) + } + + /// As `save()`, for a caller that already holds the stored snapshot. + /// + /// The publish path loads `previous` to decide whether anything changed, and `save()` was then + /// decoding the same App Group blob a second time just to reach the trace. Handing the series in + /// costs the caller nothing and removes a full JSON decode from every publish. + public func save(previousSeries: [HrPoint]) { + guard let defaults = UserDefaults(suiteName: WidgetSnapshot.suiteName) else { return } + var toStore = self + let previous = previousSeries + let nowSec = Int64(updated.timeIntervalSince1970) + toStore.hrSeries = bpm.map { HrTrace.append(previous, ts: nowSec, bpm: $0, nowSec: nowSec) } + ?? HrTrace.prune(previous, nowSec: nowSec) + guard let data = try? JSONEncoder().encode(toStore) else { return } defaults.set(data, forKey: WidgetSnapshot.storageKey) } + /// Does the TRACE need a point, even though nothing the header renders has changed? + /// + /// `renderedContentChanged` compares bpm, not history, so a steady heart — the ordinary case at rest + /// — produced no publish and therefore no new trace point. The trace would stop advancing while the + /// strap streamed happily, and pruning would eventually empty it. Android does not have this problem + /// because its PushGate re-admits an unchanged key once a minute; this is that rule. + /// + /// Keyed on the BUCKET rather than elapsed seconds, so it asks for a write exactly when + /// `HrTrace.append` would actually record one, and never more often. + static func traceNeedsPoint(previous: WidgetSnapshot?, bpm: Int?, now: Date) -> Bool { + guard let bpm, bpm > 0 else { return false } + guard let last = previous?.hrSeries?.last else { return true } + return Int64(now.timeIntervalSince1970) / HrTrace.bucketSec > last.ts / HrTrace.bucketSec + } + /// Whether publishing `next` would change anything the widget actually renders. `updated` is /// deliberately excluded: no widget family displays it, and treating a fresh timestamp as content /// would defeat deduplication because every otherwise-identical build creates a new date. diff --git a/StrandiOSShared/WidgetTelemetry.swift b/StrandiOSShared/WidgetTelemetry.swift new file mode 100644 index 0000000000..e203764e75 --- /dev/null +++ b/StrandiOSShared/WidgetTelemetry.swift @@ -0,0 +1,179 @@ +import Foundation + +/// Counters for what the home-screen widgets cost, so "the widget drains my battery" is decidable +/// from an export instead of being argued from the code. +/// +/// Twin of Android's `WidgetTelemetry`, and deliberately only HALF of it. The push accounting maps +/// one to one — `HRPublishThrottle` gates, `saveAndReloadIfChanged` either reloads or declines — so +/// the same four outcomes exist here and are worth the same counters. The bitmap accounting does not +/// map at all: this widget draws a SwiftUI `Path` inside the extension, with no bitmap and no Binder +/// transaction, so draws, bytes and MB/h would be measuring something that does not happen. +/// +/// One cost is iOS-ONLY and is the reason this is worth having rather than merely symmetrical. +/// WidgetKit budgets timeline reloads, and spending the budget does not cost battery — it makes the +/// widget go STALE, which is a correctness failure the user sees. A reloads-per-hour figure is the +/// only way to know how close the app runs to that ceiling, and nothing has ever measured it. +/// +/// Process-lifetime, reset when the process dies, reported as a rate over the steady window so a +/// short session and a long one are comparable. Not persisted: writing to disk on every publish to +/// measure the cost of publishing would be its own answer to the question. +public enum WidgetTelemetry { + + /// How long after the first publish to ignore before the rates start counting. + /// + /// The snapshot's fields populate one after another at launch and each is a content change that + /// publishes immediately, so a launch produces a burst that the once-a-minute HR throttle has + /// nothing to do with. An Android capture reported six pushes in a hundred seconds as 215/h + /// against a steady state of about sixty; the same shape applies here. + private static let warmup: TimeInterval = 60 + + private static let lock = NSLock() + private static var startedAt: Date? + private static var steadyStartedAt: Date? + private static var offered = 0 + private static var gated = 0 + private static var admitted = 0 + private static var reloaded = 0 + private static var declined = 0 + private static var steadyReloads = 0 + private static var noWidget = 0 + private static var steadyNoWidget = 0 + private static var installedKnown: Bool? + + /// A publish that `HRPublishThrottle` let through. + public static func noteAdmitted(now: Date = Date()) { + lock.lock(); defer { lock.unlock() } + if startedAt == nil { startedAt = now } + offered += 1 + admitted += 1 + if let started = startedAt, now.timeIntervalSince(started) >= warmup, steadyStartedAt == nil { + steadyStartedAt = now + } + } + + /// A publish the throttle dropped. At live-HR cadence this should dwarf the admitted count. + public static func noteGated(now: Date = Date()) { + lock.lock(); defer { lock.unlock() } + if startedAt == nil { startedAt = now } + offered += 1 + gated += 1 + } + + /// A publish that actually asked WidgetKit for a new timeline. THE figure that matters here: this + /// is what spends the reload budget. + public static func noteReloaded(now: Date = Date()) { + lock.lock(); defer { lock.unlock() } + reloaded += 1 + if steadyStartedAt != nil { steadyReloads += 1 } + } + + /// A publish that persisted but declined the reload, because nothing a widget renders had changed + /// (or only the trace advanced, which the next timeline picks up anyway). + public static func noteDeclined() { + lock.lock(); defer { lock.unlock() } + declined += 1 + } + + /// A reload asked for with no widget installed to receive it. + /// + /// Android learned this the hard way: an export taken with the widget REMOVED is half of the + /// comparison that answers whether the widget costs anything, and counting these as reloads made + /// both halves read alike. iOS never checks whether a widget is placed before calling + /// `reloadAllTimelines`, so without this the same figure would be just as blind here. + public static func noteNoWidget() { + lock.lock(); defer { lock.unlock() } + noWidget += 1 + if steadyStartedAt != nil { steadyNoWidget += 1 } + } + + /// Record what WidgetKit says about installed widgets. Refreshed on the async publish paths, which + /// is the only place an `await` is available; the synchronous live path reads the last answer. + /// + /// Nil means never asked, and nil counts as INSTALLED at the call site. Unknown must not be able to + /// invent a saving that was never made — over-reporting reloads is the safe direction for a figure + /// whose purpose is to show a cost. + public static func noteWidgetsInstalled(_ installed: Bool) { + lock.lock(); defer { lock.unlock() } + installedKnown = installed + } + + /// The last known answer, defaulting to true. See [noteWidgetsInstalled]. + public static var widgetsInstalled: Bool { + lock.lock(); defer { lock.unlock() } + return installedKnown ?? true + } + + public static func snapshot(now: Date = Date()) -> Snapshot { + lock.lock(); defer { lock.unlock() } + return Snapshot( + uptime: startedAt.map { now.timeIntervalSince($0) } ?? 0, + steady: steadyStartedAt.map { now.timeIntervalSince($0) } ?? 0, + offered: offered, + gated: gated, + admitted: admitted, + reloaded: reloaded, + declined: declined, + noWidget: noWidget, + steadyReloads: steadyReloads, + steadyNoWidget: steadyNoWidget + ) + } + + public static func resetForTest() { + lock.lock(); defer { lock.unlock() } + startedAt = nil; steadyStartedAt = nil + offered = 0; gated = 0; admitted = 0; reloaded = 0; declined = 0; steadyReloads = 0 + noWidget = 0; steadyNoWidget = 0; installedKnown = nil + } + + public struct Snapshot: Sendable, Equatable { + public let uptime: TimeInterval + public let steady: TimeInterval + public let offered: Int + public let gated: Int + public let admitted: Int + public let reloaded: Int + public let declined: Int + public let noWidget: Int + public let steadyReloads: Int + public let steadyNoWidget: Int + + /// A rate is only quoted once the steady window is long enough to mean something. Five minutes + /// of ordinary running is a handful of one-a-minute publishes; less is arithmetic. + private var steadyEnough: Bool { steady >= 5 * 60 } + + /// Timeline reloads per hour, over the steady window. + /// + /// Reloads and not publishes, because a publish that declined the reload cost nothing and + /// spent none of the budget. Counting those would make this figure unable to fall when the + /// dedup in `saveAndReloadIfChanged` did its job, which is the one thing it is for. + /// + /// A reload asked for with no widget installed is not counted here either — it is recorded as + /// [noWidget] instead of as a reload, so this figure never claims a cost that had nowhere to + /// land. + /// + /// Elapsed WALL-CLOCK time is the denominator, not time spent streaming: the ceiling this runs + /// against is expressed per hour of real time, as is a battery question. + public var reloadsPerHour: Double? { + guard steadyEnough, steady > 0 else { return nil } + return Double(steadyReloads) * 3600 / steady + } + + /// One line for the diagnostics header, matching the Android wording so two exports from the + /// same household read the same way. + public func render() -> String { + guard offered > 0 else { return "Widgets: no publishes this app session" } + var parts: [String] = ["\(reloaded) reloaded / \(admitted) admitted / \(offered) offered"] + if let rate = reloadsPerHour { + parts.append(String(format: "%.1f/h", rate)) + } + if declined > 0 { parts.append("\(declined) unchanged") } + if noWidget > 0 { parts.append("\(noWidget) with no widget installed") } + let mins = Int(uptime / 60) + let span = steadyEnough + ? "over \(mins)m" + : "over \(mins)m, steady \(Int(steady / 60))m of 5m" + return "Widgets: " + parts.joined(separator: " · ") + " (\(span))" + } + } +} diff --git a/StrandiOSWidgets/CoachBriefWidget.swift b/StrandiOSWidgets/CoachBriefWidget.swift new file mode 100644 index 0000000000..a3fa59b298 --- /dev/null +++ b/StrandiOSWidgets/CoachBriefWidget.swift @@ -0,0 +1,183 @@ +import WidgetKit +import SwiftUI +import StrandDesign + +/// K10: A Lock Screen / Home Screen widget showing the stored Coach morning brief. +/// +/// Design contract (see PRD-K10 + D8): +/// - The widget reads **stored** brief text from the App Group — it NEVER calls the network. +/// The brief is generated on a schedule by `CoachBriefScheduler` (K5) and mirrored into the +/// App Group via `publishToWidget`. The widget just displays whatever text is there. +/// - Tap → opens the Coach tab (via the app's URL scheme / deeplink). +/// - Supported families: `accessoryRectangular` (Lock Screen), `systemSmall` (Home Screen). +/// The Lock Screen accessory shows the first line; the Home Screen widget shows more. +struct CoachBriefEntry: TimelineEntry { + let date: Date + let briefText: String? + let briefDate: Date? +} + +struct CoachBriefProvider: TimelineProvider { + /// App Group keys — must match `CoachBriefScheduler.K.widgetBriefKey` / `.widgetBriefDateKey`. + private static let briefKey = "coachBrief.widgetText" + private static let briefDateKey = "coachBrief.widgetDate" + + func placeholder(in context: Context) -> CoachBriefEntry { + CoachBriefEntry( + date: Date(), + briefText: "Recovery is strong today — consider a higher-intensity session this afternoon.", + briefDate: Date() + ) + } + + func getSnapshot(in context: Context, completion: @escaping (CoachBriefEntry) -> Void) { + let entry = loadEntry() + completion(entry) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let entry = loadEntry() + // Refresh every 30 minutes — the app pushes a reload via WidgetCenter when a new brief is + // published, so this is just a safety net for when the app isn't running. + let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) + ?? Date().addingTimeInterval(1800) + completion(Timeline(entries: [entry], policy: .after(next))) + } + + private func loadEntry() -> CoachBriefEntry { + let defaults = UserDefaults(suiteName: WidgetSnapshot.suiteName) + let text = defaults?.string(forKey: CoachBriefProvider.briefKey) + let date = defaults?.object(forKey: CoachBriefProvider.briefDateKey) as? Date + return CoachBriefEntry(date: Date(), briefText: text, briefDate: date) + } +} + +struct CoachBriefWidgetView: View { + @Environment(\.widgetFamily) private var family + let entry: CoachBriefEntry + + var body: some View { + switch family { + case .accessoryRectangular: + rectangular + case .accessoryInline: + inline + default: + small + } + } + + // MARK: - Lock Screen: accessoryRectangular + + private var rectangular: some View { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Image(systemName: "sparkles") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(StrandPalette.accent) + Text("Coach") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(StrandPalette.textSecondary) + Spacer(minLength: 0) + if let date = entry.briefDate { + Text(date, style: .time) + .font(.caption2) + .foregroundStyle(StrandPalette.textTertiary) + } + } + Text(briefDisplay) + .font(.system(size: 11)) + .foregroundStyle(StrandPalette.textPrimary) + .lineLimit(3) + .minimumScaleFactor(0.8) + } + } + + // MARK: - Lock Screen: accessoryInline + + private var inline: some View { + Text(briefOneLine) + } + + // MARK: - Home Screen: systemSmall + + private var small: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Image(systemName: "sparkles") + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(StrandPalette.accent) + Text("Coach Brief") + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(StrandPalette.textSecondary) + Spacer() + } + if entry.briefText == nil { + VStack(alignment: .leading, spacing: 4) { + Text("No brief yet") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(StrandPalette.textTertiary) + Text("Enable Morning Brief in Coach settings to see today's readiness here.") + .font(.system(size: 11)) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } else { + Text(briefDisplay) + .font(.system(size: 12)) + .foregroundStyle(StrandPalette.textPrimary) + .lineLimit(5) + .minimumScaleFactor(0.8) + } + Spacer(minLength: 0) + if let date = entry.briefDate { + Text(date, format: .dateTime.hour().minute()) + .font(.caption2) + .foregroundStyle(StrandPalette.textTertiary) + } + } + .padding(12) + } + + // MARK: - Text helpers + + /// The full brief text for the widget body, or a placeholder when there's no brief. + private var briefDisplay: String { + entry.briefText ?? "No brief available." + } + + /// One-line summary for the inline accessory (capped at ~100 chars). + private var briefOneLine: String { + guard let text = entry.briefText else { return "Coach: no brief yet" } + let firstLine = text.split(separator: "\n", omittingEmptySubsequences: true) + .first.map(String.init) ?? text + let trimmed = firstLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count > 100 else { return "Coach: \(trimmed)" } + let cut = trimmed.index(trimmed.startIndex, offsetBy: 100) + return "Coach: \(trimmed[.. HeartRateEntry { + HeartRateEntry(date: Date(), snap: nil) + } + + func getSnapshot(in context: Context, completion: @escaping (HeartRateEntry) -> Void) { + completion(HeartRateEntry(date: Date(), snap: WidgetSnapshot.load())) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let entry = HeartRateEntry(date: Date(), snap: WidgetSnapshot.load()) + // The app reloads timelines when the heart rate moves, so this is only the safety net for when + // it is not running. Fifteen minutes rather than the trace's one-minute bucket: a widget cannot + // outrun its publisher, and asking more often spends budget WidgetKit would decline anyway. + let next = Calendar.current.date(byAdding: .minute, value: 15, to: Date()) + ?? Date().addingTimeInterval(900) + completion(Timeline(entries: [entry], policy: .after(next))) + } +} + +/// The trace itself: a stroked path with a fill beneath it. +/// +/// Takes its size from the geometry rather than a guess, which is the whole advantage over the Android +/// twin — there is no bitmap, so nothing has to predict the box or survive being stretched into it. +private struct HrTraceShape: Shape { + let series: [HrPoint] + /// When true, close the path down to the baseline for the gradient fill. + let filled: Bool + + func path(in rect: CGRect) -> Path { + var path = Path() + let pts = HrTrace.points(series, width: rect.width, height: rect.height) + guard let first = pts.first else { return path } + if pts.count == 1 { + // One reading is not a line. A dot says "one reading"; an empty box says "no data", and + // those are different things. + guard !filled else { return path } + let r: CGFloat = 2.5 + path.addEllipse(in: CGRect(x: max(first.x, r) - r, y: first.y - r, width: r * 2, height: r * 2)) + return path + } + path.move(to: CGPoint(x: first.x, y: first.y)) + for p in pts.dropFirst() { path.addLine(to: CGPoint(x: p.x, y: p.y)) } + if filled { + path.addLine(to: CGPoint(x: pts[pts.count - 1].x, y: rect.maxY)) + path.addLine(to: CGPoint(x: first.x, y: rect.maxY)) + path.closeSubpath() + } + return path + } +} + +struct HeartRateWidgetView: View { + let entry: HeartRateEntry + + /// Pruned on the way OUT as well as on the way in, matching the Kotlin twin. A widget rendered + /// hours after the last publish would otherwise draw a trace whose newest point is long stale, under + /// a time axis implying it is current — and WidgetKit renders an entry at ITS date, which is why the + /// window is measured from `entry.date` rather than from `Date()`. + private var series: [HrPoint] { + HrTrace.prune(entry.snap?.hrSeries ?? [], nowSec: Int64(entry.date.timeIntervalSince1970)) + } + private var stats: HrTrace.Stats? { HrTrace.stats(series) } + /// Age-checked, so an hours-old reading is not printed as current. Without this the prune above made + /// the card incoherent: the trace emptied while the headline kept its confident number. + private var shown: (bpm: Int?, stale: Bool) { + HrDisplay.resolve(bpm: entry.snap?.bpm, newestPointTs: series.last?.ts, now: entry.date) + } + /// The palette's HR zone-5 token, which resolves to exactly the hexes the Android widget carries as + /// a local mirror (#C84E1E / #E0662F) — so the two widgets are the same colour rather than two + /// approximations of one. Named rather than hardcoded here because, unlike Glance, this target can + /// read the design package; and being a token it follows the Classic theme where a hex could not. + private var accent: Color { StrandPalette.zone5 } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: "heart.fill") + .font(.system(size: 12)) + .foregroundStyle(accent) + Text("Heart rate") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(StrandPalette.textPrimary) + } + + HStack(alignment: .lastTextBaseline, spacing: 4) { + Text(shown.bpm.map(String.init) ?? "—") + .font(.system(size: 30, weight: .bold, design: .rounded)) + .foregroundStyle(shown.stale ? StrandPalette.textSecondary : StrandPalette.textPrimary) + if shown.bpm != nil { + Text("bpm") + .font(.system(size: 12)) + .foregroundStyle(StrandPalette.textSecondary) + } + if let stats { + Text("Min \(stats.min) • Max \(stats.max)") + .font(.system(size: 11)) + .foregroundStyle(StrandPalette.textPrimary) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(accent.opacity(0.18), in: Capsule()) + .padding(.leading, 6) + } + } + + if !series.isEmpty { + HStack(alignment: .top, spacing: 6) { + HrTraceChart(series: series, accent: accent) + // A scale of one repeated number says nothing the headline has not, so it waits for + // a range — the same rule the Android twin follows. + if let stats, stats.max > stats.min { + // Ticks computed ONCE, and the gap keyed on the INDEX. Comparing the value to + // `.last` happened to work only because a scale is drawn solely when there is a + // range; with two equal ticks it would have dropped a spacer and skewed the scale. + let ticks = HrTrace.bpmTicks(stats) + VStack(alignment: .trailing) { + ForEach(Array(ticks.enumerated()), id: \.offset) { index, tick in + Text("\(tick)") + .font(.system(size: 10)) + .foregroundStyle(StrandPalette.textSecondary) + if index < ticks.count - 1 { Spacer(minLength: 0) } + } + } + } + } + HrTimeAxis(series: series) + } + + Spacer(minLength: 0) + if let updated = entry.snap?.updated, updated != .distantPast { + HStack { + Spacer() + Text("Updated \(updated, format: .dateTime.hour().minute())") + .font(.system(size: 10)) + .foregroundStyle(StrandPalette.textSecondary) + Spacer() + } + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityText) + } + + /// One spoken sentence rather than a run of loose numbers. The Android twin had to settle for + /// per-label descriptions because Glance cannot mark a Text decorative; SwiftUI can combine the + /// whole card, so it does. Staleness is not encoded in colour here at all, so nothing needs saying + /// about it. + private var accessibilityText: String { + // String(localized:) rather than bare literals. The audit DOES scan `.accessibilityLabel(`, but + // it matches a literal sitting immediately after the paren — and this is a computed property, so + // extracting the sentence here hid it from the check. Hardcoded English would have shipped to + // every locale with the gate green, which is the same trap the Kotlin twin records for copy + // written inside a semantics {} lambda. + guard let bpm = shown.bpm else { return String(localized: "Heart rate, no reading") } + guard let stats else { return String(localized: "Heart rate \(bpm) bpm") } + return String(localized: "Heart rate \(bpm) bpm, minimum \(stats.min), maximum \(stats.max)") + } +} + +private struct HrTraceChart: View { + let series: [HrPoint] + let accent: Color + + var body: some View { + ZStack { + HrTraceShape(series: series, filled: true) + .fill(LinearGradient( + colors: [accent.opacity(0.35), accent.opacity(0)], + startPoint: .top, endPoint: .bottom, + )) + HrTraceShape(series: series, filled: false) + .stroke(accent, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +private struct HrTimeAxis: View { + let series: [HrPoint] + + var body: some View { + let ticks = HrTrace.timeTicks(series) + // One instant pinned to the left edge reads as a stray rather than an axis, so it waits for a + // span to label — matching the twin. + if ticks.count >= 2 { + HStack { + ForEach(Array(ticks.enumerated()), id: \.offset) { i, ts in + Text(Date(timeIntervalSince1970: TimeInterval(ts)), + format: .dateTime.hour().minute()) + .font(.system(size: 9)) + .foregroundStyle(StrandPalette.textSecondary) + if i < ticks.count - 1 { Spacer(minLength: 0) } + } + } + } + } +} + +struct HeartRateWidget: Widget { + static let kind = "HeartRateWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: Self.kind, provider: HeartRateProvider()) { entry in + if #available(iOS 17.0, *) { + HeartRateWidgetView(entry: entry) + .containerBackground(StrandPalette.surfaceBase, for: .widget) + } else { + HeartRateWidgetView(entry: entry) + .padding() + .background(StrandPalette.surfaceBase) + } + } + .configurationDisplayName("Heart Rate") + .description("Live heart rate with the last three hours as a trace.") + .supportedFamilies([.systemMedium]) + } +} diff --git a/StrandiOSWidgets/NOOPWidget.swift b/StrandiOSWidgets/NOOPWidget.swift index 5e55dc7ebf..d149ba447f 100644 --- a/StrandiOSWidgets/NOOPWidget.swift +++ b/StrandiOSWidgets/NOOPWidget.swift @@ -29,10 +29,14 @@ struct NOOPProvider: TimelineProvider { } /// The glanceable widget — the iOS analogue of the macOS menu-bar extra. -/// Home Screen families mirror Today's hero trio (Charge · Effort · Rest) as score rings; Lock Screen -/// accessories stay compact single-line / gauge layouts. +/// Home Screen families mirror Today's hero trio (Charge · Effort · Rest) as score rings. Lock Screen +/// accessories are compact: a single line, a gauge, or the rectangular glyph-over-value trio. struct NOOPWidgetView: View { @Environment(\.widgetFamily) private var family + /// `.fullColor` on the home screen and in the gallery; `.vibrant` or `.accented` on the lock screen, + /// where the system desaturates any colour it is handed. The accessory cells check this rather than + /// tinting unconditionally — see `accessoryScore`. + @Environment(\.widgetRenderingMode) private var renderingMode let entry: NOOPEntry private var snap: WidgetSnapshot { entry.snapshot } @@ -97,37 +101,75 @@ struct NOOPWidgetView: View { /// Lock-Screen rectangular accessory: Charge · Effort · Rest, same trio as the Home Screen rings. private var rectangular: some View { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 4) { - Text("NOOP") - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(StrandPalette.textSecondary) - Spacer(minLength: 0) - if let bpm = snap.bpm { - Text("\(bpm) bpm") - .font(.caption2) - .foregroundStyle(StrandPalette.textTertiary) - } + // The lock screen gives this family roughly 72pt of height for everything. A "NOOP" title spent + // a whole row of that restating which widget the user chose to add, leaving the three scores — + // the only reason to add it — squeezed underneath. The title is gone and the heart-rate line is + // now conditional, so with no live HR the scores get the entire area. + VStack(spacing: 2) { + if let bpm = snap.bpm { + Text("\(bpm) bpm") + .font(.caption2) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .trailing) } HStack(alignment: .top, spacing: 0) { - accessoryScore("Charge", text: snap.recovery.map { "\($0)%" }, tint: chargeColor) - accessoryScore("Effort", text: effortText, tint: effortColor) - accessoryScore("Rest", text: snap.rest.map { "\($0)%" }, tint: restColor) + accessoryScore("Charge", symbol: "figure.mind.and.body", + text: snap.recovery.map { "\($0)%" }, tint: chargeColor) + accessoryScore("Effort", symbol: "figure.strengthtraining.traditional", + text: effortText, tint: effortColor) + accessoryScore("Rest", symbol: "moon.fill", + text: snap.rest.map { "\($0)%" }, tint: restColor) } } } - private func accessoryScore(_ label: String, text: String?, tint: Color) -> some View { - VStack(alignment: .leading, spacing: 0) { + /// One score cell. On the LOCK SCREEN the domain tint is deliberately dropped; it survives only + /// where the system actually renders full colour, which for this family means a gallery preview. + /// + /// Lock-screen widgets render in `.vibrant` (or `.accented`), where the system desaturates whatever + /// colour it is given and maps it onto the wallpaper. A domain colour handed to it does not survive + /// as that colour — it lands as an arbitrary grey whose luminance nobody chose, so Charge, Effort and + /// Rest stopped being distinguishable AND stopped being legible. `.primary`/`.secondary` are the two + /// levels the system is designed to map, so the value reads at full strength and the glyph above it + /// recedes, which is the hierarchy the tint was there to express in the first place. + /// + /// The `.fullColor` branch is defensive rather than hot: this family renders `.vibrant` on the lock + /// screen and in StandBy, so the tint realistically only reaches a gallery preview. + private func accessoryScore(_ label: String, symbol: String, text: String?, tint: Color) -> some View { + VStack(spacing: 1) { + // Glyph over value, the shape the request asked for. A 9pt word under each number was + // spending scarce height on text nobody needs twice — the icons carry the metric identity. + Image(systemName: symbol) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(renderingMode == .fullColor + ? AnyShapeStyle(StrandPalette.textTertiary) + : AnyShapeStyle(HierarchicalShapeStyle.secondary)) Text(text ?? "–") - .font(.system(size: 16, weight: .semibold, design: .rounded)) - .foregroundStyle(text == nil ? StrandPalette.textTertiary : tint) + .font(.system(size: 15, weight: .semibold, design: .rounded)) + .foregroundStyle(scoreStyle(hasValue: text != nil, tint: tint)) .minimumScaleFactor(0.7) - Text(label) - .font(.system(size: 9)) - .foregroundStyle(StrandPalette.textTertiary) } - .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: .infinity) + // An icon says nothing to VoiceOver, and the word it replaced was the only thing naming this + // metric. Collapse the cell to one element that still speaks "Charge, 68 percent". + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(label)) + // Plain literal, not String(localized:). This extension's sources are StrandiOSWidgets + + // StrandiOSShared only — Strand/Resources/Localizable.xcstrings is NOT in the target, and + // String(localized:) resolves against Bundle.main, which for an app extension is the extension's + // own bundle. It would compile, look localized, and render English in every locale. Every other + // string in this file is a bare literal for the same reason: the widget is not localized yet. + .accessibilityValue(Text(text ?? "No data")) + } + + private func scoreStyle(hasValue: Bool, tint: Color) -> AnyShapeStyle { + // Spelled out rather than leaning on leading-dot inference through AnyShapeStyle's generic + // init, which is the kind of expression that type-checks in a playground and not in a build. + guard renderingMode == .fullColor else { + return hasValue ? AnyShapeStyle(HierarchicalShapeStyle.primary) + : AnyShapeStyle(HierarchicalShapeStyle.secondary) + } + return hasValue ? AnyShapeStyle(tint) : AnyShapeStyle(StrandPalette.textTertiary) } // MARK: - Home Screen: systemSmall @@ -167,10 +209,18 @@ struct NOOPWidgetView: View { scoreRings(diameter: 88, lineWidth: 8, labelFont: .caption) Divider() HStack(alignment: .top, spacing: 0) { - statCell("HRV", value: snap.hrv.map { "\($0)" }, unit: "ms") - statCell("Rest HR", value: snap.restingHr.map { "\($0)" }, unit: "bpm") - statCell("HR", value: snap.bpm.map { "\($0)" }, unit: "bpm") - statCell("Battery", value: snap.batteryPct.map { "\($0)%" }) + statCell("HRV", value: snap.hrv.map { "\($0)" }, unit: "ms", + name: "Heart rate variability", + spoken: snap.hrv.map { "\($0) milliseconds" }) + statCell("Rest HR", value: snap.restingHr.map { "\($0)" }, unit: "bpm", + name: "Resting heart rate", + spoken: snap.restingHr.map { "\($0) beats per minute" }) + statCell("HR", value: snap.bpm.map { "\($0)" }, unit: "bpm", + name: "Heart rate", + spoken: snap.bpm.map { "\($0) beats per minute" }) + statCell("Battery", value: snap.batteryPct.map { "\($0)%" }, + name: "Strap battery", + spoken: snap.batteryPct.map { "\($0) percent" }) } Spacer(minLength: 0) } @@ -231,24 +281,66 @@ struct NOOPWidgetView: View { .frame(maxWidth: .infinity) } + /// Home-Screen footer: heart rate, HRV, strap battery, under the score rings. + /// + /// `heart.fill` is HR and `waveform.path.ecg` is HRV, which is the metric pairing the rest of the app + /// uses — `TodayView` renders them as adjacent rows that way, and `DashboardCards`, + /// `TodayCustomizationMetadata`, `MetricCatalog` and this extension's own Live Activity all agree. + /// The two were inverted here from #1022 until #1795 reported it. + /// + /// The inversion was an easy one to make and is worth naming so it is not "corrected" back: + /// `waveform.path.ecg` IS the right symbol for Live HR as a FEATURE — `RootView`, `RootTabView` and + /// `HomeScreenQuickActions` all use it for the Live screen. As a METRIC icon it belongs to HRV, and + /// this footer is the one place that renders both metrics side by side, where the collision is the + /// entire problem: nothing here carries a text label, so the glyph is the only thing naming a number. private func vitalsFooter(compact: Bool) -> some View { HStack { - Label(snap.bpm.map(String.init) ?? "–", systemImage: "waveform.path.ecg") + vital(symbol: "heart.fill", text: snap.bpm.map(String.init), + name: "Heart rate", spoken: snap.bpm.map { "\($0) beats per minute" }) Spacer() if !compact, let hrv = snap.hrv { - Label("\(hrv)", systemImage: "heart.fill") + vital(symbol: "waveform.path.ecg", text: "\(hrv)", + name: "Heart rate variability", spoken: "\(hrv) milliseconds") Spacer() } - Label(snap.batteryPct.map { "\($0)%" } ?? "–", systemImage: "battery.50") + vital(symbol: "battery.50", text: snap.batteryPct.map { "\($0)%" }, + name: "Strap battery", spoken: snap.batteryPct.map { "\($0) percent" }) } .font(.caption2) .foregroundStyle(StrandPalette.textSecondary) .labelStyle(.titleAndIcon) } + /// One footer vital. `spoken` is carried separately from the rendered `text` because VoiceOver gets + /// neither of the two things a sighted reader uses here: the glyph that names the metric, and the + /// unit, which the footer shows for none of them. Without this a reader hears "58", "64", "84 percent" + /// — three anonymous numbers. Same reasoning as `accessoryScore`, which #1715 fixed for the lock + /// screen; this footer predates it. + /// + /// Plain literals rather than `String(localized:)`, for the reason spelled out on `accessoryScore`: + /// this extension does not carry the app's string catalog, so `String(localized:)` would look + /// localized and render English anyway. + private func vital(symbol: String, text: String?, name: String, spoken: String?) -> some View { + Label(text ?? "–", systemImage: symbol) + // Collapse first, like `accessoryScore` and `WidgetScoreRing` already do. A `Label` under + // `.titleAndIcon` renders an image beside a text, so without this the bare number stays its + // own element and whether the label below wins is SwiftUI container semantics rather than + // something this file decides. Every other labelled graphic here ignores its children. + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(name)) + .accessibilityValue(Text(spoken ?? "No data")) + } + /// One labelled stat in the large grid — value over a caption, equal-width so the columns align. + /// + /// `name` and `spoken` exist because the on-screen caption is abbreviated for width and the unit is a + /// separate view: read as-is, VoiceOver produces "64", "ms", "HRV" — three fragments, value before + /// label, with "ms" and "bpm" spelled out letter by letter. Collapsing to one element lets the cell + /// speak "Heart rate variability, 64 milliseconds". `spoken` falls back to the rendered value rather + /// than to "No data", so a caller that omits it degrades to the old reading instead of lying. private func statCell(_ label: String, value: String?, unit: String? = nil, - tint: Color = StrandPalette.textPrimary) -> some View { + tint: Color = StrandPalette.textPrimary, + name: String? = nil, spoken: String? = nil) -> some View { VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline, spacing: 2) { Text(value ?? "–") @@ -261,6 +353,9 @@ struct NOOPWidgetView: View { Text(label).font(.caption2).foregroundStyle(StrandPalette.textTertiary) } .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(name ?? label)) + .accessibilityValue(Text(spoken ?? value ?? "No data")) } } diff --git a/StrandiOSWidgets/NOOPWidgetBundle.swift b/StrandiOSWidgets/NOOPWidgetBundle.swift index d1a12cf054..58ee16e311 100644 --- a/StrandiOSWidgets/NOOPWidgetBundle.swift +++ b/StrandiOSWidgets/NOOPWidgetBundle.swift @@ -1,11 +1,15 @@ import WidgetKit import SwiftUI -/// The widget extension entry point. Bundles the glanceable widget and the live-HR Live Activity. +/// The widget extension entry point. Bundles the glanceable widget, the live-HR Live Activity, +/// the K10 Coach brief widget (stored morning brief on Lock Screen / Home Screen), and the +/// heart-rate trace widget (#1957). @main struct NOOPWidgetBundle: WidgetBundle { var body: some Widget { NOOPWidget() NOOPLiveActivity() + CoachBriefWidget() + HeartRateWidget() } } diff --git a/Tools/doc_comment_lint.py b/Tools/doc_comment_lint.py index a82755c2a6..b25c32c920 100644 --- a/Tools/doc_comment_lint.py +++ b/Tools/doc_comment_lint.py @@ -130,7 +130,11 @@ def main() -> int: scanned += 1 found = findings(path) if found: - rel = str(path.relative_to(ROOT)) + # as_posix(), not str(): the baseline is written with forward slashes, so on Windows + # str() yields "android\app\..." and every baseline lookup misses. That made the gate fail + # on a clean tree there (every grandfathered site read as a regression, and every + # baselined file as IMPROVED -> 0), so a Windows contributor could not run it locally. + rel = path.relative_to(ROOT).as_posix() by_file[rel] = [f"{rel}:{ln} {why}" for ln, why in found] regressions = [h for f, hs in sorted(by_file.items()) diff --git a/Tools/doc_comment_lint_baseline.txt b/Tools/doc_comment_lint_baseline.txt index 1a6ea29e44..87dffc43b9 100644 --- a/Tools/doc_comment_lint_baseline.txt +++ b/Tools/doc_comment_lint_baseline.txt @@ -3,7 +3,6 @@ # file not listed here has any. Ratchet DOWN as they are fixed — never up. # Generated at the time Tools/doc_comment_lint.py landed; see #846 for the pattern. -android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt 1 android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt 5 android/app/src/main/java/com/noop/ble/WhoopBleClient.kt 8 android/app/src/main/java/com/noop/ble/WhoopConnectionService.kt 1 diff --git a/Tools/i18n/ar_wip.json b/Tools/i18n/ar_wip.json new file mode 100644 index 0000000000..b649ca0906 --- /dev/null +++ b/Tools/i18n/ar_wip.json @@ -0,0 +1,4634 @@ +{ + "_note": "WORK IN PROGRESS - machine-translated Arabic for Android, NOT a shipping locale. Each entry stores the ENGLISH SOURCE alongside the Arabic on purpose: 856 of these 1147 keys embed a sha1 of the English text, so editing that copy changes the key and would silently orphan the translation. Re-match on 'en' if a key no longer resolves. The stored 'en' is the RAW value from values/strings.xml, so it keeps that file's backslash escapes (69 entries contain \\' for an apostrophe); re-match with the same extraction rather than against rendered text.", + "_status": { + "translated": 1147, + "still_to_translate": 1312, + "untranslatable_english_fallback": 121, + "base_english_strings": 2581 + }, + "_glossary": { + "Charge": "الشحن", + "Effort": "الجهد", + "Rest": "الراحة", + "Recovery": "التعافي", + "Strain": "الإجهاد", + "Sleep": "النوم", + "Readiness": "الجاهزية", + "HRV": "تقلب النبض", + "heart rate": "معدل النبض", + "bpm": "نبضة/دقيقة", + "strap": "السوار", + "baseline": "خط الأساس", + "Workout": "تمرين", + "Workouts": "التمارين", + "Steps": "الخطوات", + "Calories": "السعرات", + "Today": "اليوم", + "Trends": "الاتجاهات", + "Insights": "الرؤى", + "Journal": "اليوميات", + "Settings": "الإعدادات", + "Sleep Debt": "دين النوم", + "Nap": "قيلولة", + "Battery": "البطارية", + "Live": "مباشر", + "Devices": "الأجهزة", + "Automations": "الأتمتة", + "Stress": "التوتر", + "Breathe": "التنفس", + "Hydration": "الترطيب", + "Coach": "المدرب الذكي", + "Explore": "استكشاف" + }, + "strings": { + "action_breathe": { + "en": "Breathe", + "ar": "التنفس" + }, + "action_live_hr": { + "en": "Live HR", + "ar": "النبض المباشر" + }, + "action_log_journal": { + "en": "Log journal", + "ar": "تسجيل اليوميات" + }, + "action_start_workout": { + "en": "Start workout", + "ar": "بدء تمرين" + }, + "battery_bedtime_body": { + "en": "%1$s of recording left, but tonight needs about %2$s. Charge before bed.", + "ar": "يتبقى %1$s من التسجيل، لكن الليلة تحتاج نحو %2$s. اشحن السوار قبل النوم." + }, + "battery_bedtime_title": { + "en": "Won\\'t last the night", + "ar": "لن يكفي طوال الليل" + }, + "battery_channel_desc": { + "en": "Alerts when the strap battery is low or fully charged.", + "ar": "تنبيهات عند انخفاض بطارية السوار أو اكتمال شحنها." + }, + "battery_channel_name": { + "en": "Battery alerts", + "ar": "تنبيهات البطارية" + }, + "battery_critical_body": { + "en": "%1$d%% left. The strap stops recording near 10%% — it won\\'t capture tonight unless you charge it.", + "ar": "يتبقى %1$d%%. يتوقف السوار عن التسجيل قرب 10%% — ولن يسجّل هذه الليلة ما لم تشحنه." + }, + "battery_critical_title": { + "en": "Charge your WHOOP now", + "ar": "اشحن WHOOP الآن" + }, + "battery_full_body": { + "en": "Your WHOOP is at 100%.", + "ar": "شحن WHOOP بلغ 100%." + }, + "battery_full_title": { + "en": "Strap fully charged", + "ar": "اكتمل شحن السوار" + }, + "battery_low_body": { + "en": "Recharge your WHOOP before tonight.", + "ar": "أعد شحن WHOOP قبل الليلة." + }, + "battery_low_title": { + "en": "Low battery", + "ar": "البطارية منخفضة" + }, + "battery_runtime_body": { + "en": "%1$s left on your WHOOP — recharge tonight.", + "ar": "يتبقى %1$s في WHOOP — أعد شحنه الليلة." + }, + "battery_runtime_title": { + "en": "Strap battery low", + "ar": "بطارية السوار منخفضة" + }, + "breath_presence_intro_body": { + "en": "Consciously Connected Breathing (CCB): inhale and exhale in one continuous loop with no hold at the top or bottom. Find a comfortable rhythm — connectedness matters more than intensity. Typical practice is about 15 minutes, twice daily. Regular is the sustainable starter tempo; Mid is a quicker start for later rounds; Punching Through is only when you need to push through drowsiness or stuckness (not a beginner default).", + "ar": "التنفس المتصل الواعي (CCB): شهيق وزفير في حلقة متصلة واحدة دون حبس في الأعلى أو الأسفل. اعثر على إيقاع مريح — الاتصال أهم من الشدة. الممارسة المعتادة نحو 15 دقيقة مرتين يوميًا. «العادي» هو الإيقاع المستدام للبداية؛ و«المتوسط» بداية أسرع للجولات اللاحقة؛ و«الاختراق» فقط عند الحاجة إلى تجاوز النعاس أو الجمود (ليس الخيار الافتراضي للمبتدئين)." + }, + "breath_presence_intro_title": { + "en": "The Presence Process", + "ar": "عملية الحضور" + }, + "breath_proto_bhastrika_caution": { + "en": "Stop if dizzy. Avoid with high blood pressure, pregnancy, or recent surgery unless cleared by a clinician.", + "ar": "توقف عند الشعور بالدوار. تجنّبه مع ارتفاع ضغط الدم أو الحمل أو بعد جراحة حديثة ما لم يأذن الطبيب." + }, + "breath_proto_bhastrika_edu": { + "en": "Bhastrika (bellows): forceful 1-second nasal inhale and exhale. Energising and intense. Keep rounds short and rest between them.", + "ar": "باستريكا (المنفاخ): شهيق وزفير أنفي قوي لمدة ثانية واحدة. منشّط وشديد. أبقِ الجولات قصيرة مع راحة بينها." + }, + "breath_proto_bhastrika_session_hint": { + "en": "Short rounds only.", + "ar": "جولات قصيرة فقط." + }, + "breath_proto_bhastrika_subtitle": { + "en": "Forceful 1s in · 1s out", + "ar": "قوي · ثانية شهيق · ثانية زفير" + }, + "breath_proto_box_4_4_4_4_edu": { + "en": "Box (square) breathing: inhale, hold, exhale, hold — each for 4 seconds. A structured rhythm often used for focus and settling under stress. Sit comfortably and keep the counts even.", + "ar": "التنفس المربّع: شهيق، حبس، زفير، حبس — أربع ثوانٍ لكل مرحلة. إيقاع منظم يُستخدم غالبًا للتركيز والتهدئة تحت الضغط. اجلس مرتاحًا وحافظ على تساوي العدّات." + }, + "breath_proto_box_4_4_4_4_session_hint": { + "en": "About 5 minutes per session; repeat as needed.", + "ar": "نحو 5 دقائق لكل جلسة؛ كرّرها حسب الحاجة." + }, + "breath_proto_box_4_4_4_4_subtitle": { + "en": "Square breath · steady focus", + "ar": "نفَس مربّع · تركيز ثابت" + }, + "breath_proto_buteyko_caution": { + "en": "Stay in a comfortable range; never force breath holds.", + "ar": "ابقَ ضمن نطاق مريح؛ لا تُجبر حبس النفس أبدًا." + }, + "breath_proto_buteyko_edu": { + "en": "Buteyko-style reduced breathing: small nasal inhale (~2.5s), relaxed nasal exhale (~3.5s), short comfortable hold (~3.5s). Keep it quiet and light — not a big gasp.", + "ar": "تنفس مخفَّض بأسلوب بوتيكو: شهيق أنفي صغير (~2.5 ث)، زفير أنفي مسترخٍ (~3.5 ث)، ثم حبس قصير مريح (~3.5 ث). أبقِه هادئًا وخفيفًا — لا شهقة كبيرة." + }, + "breath_proto_buteyko_reduced_caution": { + "en": "Never push into panic or strong air hunger.", + "ar": "لا تدفع نفسك إلى الذعر أو الجوع الشديد للهواء." + }, + "breath_proto_buteyko_reduced_edu": { + "en": "Reduced Buteyko-style: ~2s gentle inhale, ~3s exhale, then a 3–5s hold with mild air hunger only. Stay relaxed in the face and shoulders.", + "ar": "بوتيكو المخفَّض: شهيق لطيف ~2 ث، زفير ~3 ث، ثم حبس 3–5 ث مع جوع خفيف للهواء فقط. أبقِ الوجه والكتفين مسترخيين." + }, + "breath_proto_buteyko_reduced_session_hint": { + "en": "15–20 minutes if comfortable.", + "ar": "15–20 دقيقة إن كان مريحًا." + }, + "breath_proto_buteyko_reduced_subtitle": { + "en": "Lighter · longer soft hold", + "ar": "أخف · حبس ليّن أطول" + }, + "breath_proto_buteyko_reduced_title": { + "en": "Buteyko Reduced", + "ar": "بوتيكو المخفَّض" + }, + "breath_proto_buteyko_session_hint": { + "en": "3–20 minutes, as comfortable.", + "ar": "3–20 دقيقة، حسب الراحة." + }, + "breath_proto_buteyko_subtitle": { + "en": "Gentle nasal · light air hunger", + "ar": "أنفي لطيف · جوع خفيف للهواء" + }, + "breath_proto_buteyko_title": { + "en": "Buteyko", + "ar": "بوتيكو" + }, + "breath_proto_coherence_5_5_edu": { + "en": "Equal inhale and exhale at about 5.5 breaths per minute — a common coherence / resonance starting point. Steady, balanced pacing for settling heart-rate variability during a session.", + "ar": "شهيق وزفير متساويان عند نحو 5.5 نفَس في الدقيقة — نقطة انطلاق شائعة للاتساق والرنين. إيقاع ثابت متوازن لتهدئة تقلب النبض خلال الجلسة." + }, + "breath_proto_coherence_5_5_session_hint": { + "en": "Try about 10 minutes.", + "ar": "جرّب نحو 10 دقائق." + }, + "breath_proto_coherence_5_5_subtitle": { + "en": "Equal breath · ~5.5 br/min coherence", + "ar": "نفَس متساوٍ · اتساق ~5.5 نفَس/دقيقة" + }, + "breath_proto_coherence_5_5_title": { + "en": "Coherence 5.5", + "ar": "الاتساق 5.5" + }, + "breath_proto_coherent_6_6_edu": { + "en": "Coherent breathing at six seconds in and six seconds out (~5 breaths per minute). A slow equal pace aimed at calm and HRV-friendly rhythm — estimate only, not a clinical reading.", + "ar": "تنفس متسق بست ثوانٍ شهيقًا وست ثوانٍ زفيرًا (~5 أنفاس في الدقيقة). إيقاع بطيء متساوٍ يهدف إلى الهدوء وإيقاع ملائم لتقلب النبض — تقدير فقط وليس قراءة سريرية." + }, + "breath_proto_coherent_6_6_session_hint": { + "en": "Start around 10 minutes.", + "ar": "ابدأ بنحو 10 دقائق." + }, + "breath_proto_coherent_6_6_subtitle": { + "en": "6s in · 6s out · ~5 br/min", + "ar": "6 ث شهيق · 6 ث زفير · ~5 نفَس/دقيقة" + }, + "breath_proto_coherent_6_6_title": { + "en": "Coherent 6-6", + "ar": "المتسق 6-6" + }, + "breath_proto_deep_4_2_6_edu": { + "en": "Deep breathing with a short pause: inhale 4s, hold 2s, exhale 6s. Breathe through the nose when you can, fill the lungs without forcing, and let the longer exhale slow you down.", + "ar": "تنفس عميق مع وقفة قصيرة: شهيق 4 ث، حبس ثانيتان، زفير 6 ث. تنفّس عبر الأنف متى استطعت، واملأ الرئتين دون إجهاد، ودع الزفير الأطول يبطئك." + }, + "breath_proto_deep_4_2_6_session_hint": { + "en": "5–10 minutes daily is a common starting point.", + "ar": "5–10 دقائق يوميًا نقطة انطلاق شائعة." + }, + "breath_proto_deep_4_2_6_subtitle": { + "en": "Slow inhale · soft hold · long exhale", + "ar": "شهيق بطيء · حبس ليّن · زفير طويل" + }, + "breath_proto_deep_4_2_6_title": { + "en": "Deep Breathing", + "ar": "التنفس العميق" + }, + "breath_proto_diaphragmatic_4_2_6_edu": { + "en": "Diaphragmatic (belly) breathing: expand the abdomen on the inhale more than the chest, optional short hold, then gently contract on the exhale. Same 4–2–6 timing as deep breathing, with attention on the diaphragm.", + "ar": "التنفس الحجابي (البطني): وسّع البطن مع الشهيق أكثر من الصدر، مع حبس قصير اختياري، ثم اسحبه بلطف مع الزفير. التوقيت نفسه 4–2–6 كالتنفس العميق، مع تركيز على الحجاب الحاجز." + }, + "breath_proto_diaphragmatic_4_2_6_session_hint": { + "en": "5–10 minutes daily.", + "ar": "5–10 دقائق يوميًا." + }, + "breath_proto_diaphragmatic_4_2_6_subtitle": { + "en": "Belly breath · soft pause", + "ar": "نفَس بطني · وقفة ليّنة" + }, + "breath_proto_diaphragmatic_4_2_6_title": { + "en": "Diaphragmatic", + "ar": "حجابي" + }, + "breath_proto_four_seven_eight_caution": { + "en": "If you feel dizzy or uncomfortable, stop and breathe normally.", + "ar": "إذا شعرت بدوار أو انزعاج، توقف وتنفّس بشكل طبيعي." + }, + "breath_proto_four_seven_eight_edu": { + "en": "Classic 4-7-8: quiet nasal inhale for 4, hold for 7, long mouth exhale for 8. Often used to wind down. Start with a few cycles; stop if you feel light-headed.", + "ar": "الكلاسيكي 4-7-8: شهيق أنفي هادئ لأربع، حبس لسبع، زفير فموي طويل لثمان. يُستخدم غالبًا للاسترخاء. ابدأ بدورات قليلة؛ وتوقف إن شعرت بخفة في الرأس." + }, + "breath_proto_four_seven_eight_session_hint": { + "en": "A few minutes is enough; build gradually.", + "ar": "بضع دقائق تكفي؛ زدها تدريجيًا." + }, + "breath_proto_four_seven_eight_subtitle": { + "en": "Inhale 4 · hold 7 · exhale 8", + "ar": "شهيق 4 · حبس 7 · زفير 8" + }, + "breath_proto_holotropic_caution": { + "en": "Not for DIY high-intensity sessions if you have trauma history, cardiovascular issues, or pregnancy without professional guidance.", + "ar": "غير مناسب لجلسات مكثفة ذاتية إن كان لديك تاريخ صدمة أو مشكلات قلبية وعائية أو حمل، دون إشراف مختص." + }, + "breath_proto_holotropic_edu": { + "en": "Holotropic-style work is continuous, often rapid connected breathing in a supported setting. NOOP only offers a session timer and education — not an auto-pacer — because intensity varies widely and is normally facilitated.", + "ar": "العمل بأسلوب الهولوتروبيك تنفس متصل مستمر وسريع غالبًا في بيئة مدعومة. يقدّم NOOP مؤقّت جلسة وشرحًا فقط — لا ضابط إيقاع تلقائي — لأن الشدة تتفاوت كثيرًا وتُدار عادة بإشراف." + }, + "breath_proto_holotropic_session_hint": { + "en": "Traditional sessions are long; use a short timer here as a check-in only.", + "ar": "الجلسات التقليدية طويلة؛ استخدم مؤقّتًا قصيرًا هنا للمتابعة فقط." + }, + "breath_proto_holotropic_subtitle": { + "en": "Guided · continuous connected", + "ar": "موجّه · متصل مستمر" + }, + "breath_proto_holotropic_title": { + "en": "Holotropic", + "ar": "هولوتروبيك" + }, + "breath_proto_kapalabhati_caution": { + "en": "Skip if pregnant, dizzy, or have uncontrolled hypertension. Stop if strained.", + "ar": "تجنّبه أثناء الحمل أو عند الدوار أو مع ارتفاع ضغط غير منضبط. توقف عند الإجهاد." + }, + "breath_proto_kapalabhati_edu": { + "en": "Kapalabhati uses rapid, active belly exhales with passive inhales. Timing varies by person — this mode gives you a timer and coaching text only, not a forced metronome. Practice seated, short rounds, rest between.", + "ar": "كابالاباتي يستخدم زفيرًا بطنيًا سريعًا نشطًا مع شهيق سلبي. يختلف التوقيت من شخص لآخر — يمنحك هذا الوضع مؤقّتًا ونصًا إرشاديًا فقط، لا إيقاعًا إجباريًا. مارسه جالسًا، بجولات قصيرة وراحة بينها." + }, + "breath_proto_kapalabhati_session_hint": { + "en": "Short rounds (about 30–60s) with rests.", + "ar": "جولات قصيرة (نحو 30–60 ثانية) مع راحة." + }, + "breath_proto_kapalabhati_subtitle": { + "en": "Guided · rapid belly pulses", + "ar": "موجّه · نبضات بطنية سريعة" + }, + "breath_proto_kapalabhati_title": { + "en": "Kapalabhati", + "ar": "كابالاباتي" + }, + "breath_proto_nadi_shodhana_edu": { + "en": "Alternate-nostril breathing: inhale left, hold, exhale right, inhale right, hold, exhale left — about 4 seconds each step. Use a finger to gently close one nostril at a time. Labels cue which side; go at a calm pace.", + "ar": "التنفس بالمنخرين بالتناوب: شهيق يسار، حبس، زفير يمين، شهيق يمين، حبس، زفير يسار — نحو 4 ثوانٍ لكل خطوة. استخدم إصبعًا لإغلاق منخر واحد برفق في كل مرة. تشير التسميات إلى الجهة؛ حافظ على وتيرة هادئة." + }, + "breath_proto_nadi_shodhana_session_hint": { + "en": "5–10 minutes.", + "ar": "5–10 دقائق." + }, + "breath_proto_nadi_shodhana_subtitle": { + "en": "Nadi Shodhana · left/right cycle", + "ar": "نادي شودانا · دورة يسار/يمين" + }, + "breath_proto_nadi_shodhana_title": { + "en": "Alternate Nostril", + "ar": "تناوب المنخرين" + }, + "breath_proto_presence_mid_edu": { + "en": "Mid session tempo (~2.4s / 2.4s) is a starting pace for later passes through the procedure; once settled you may slow deeper or switch to Punching Through.", + "ar": "إيقاع الجلسة المتوسط (~2.4 ث / 2.4 ث) وتيرة انطلاق للمرات اللاحقة من العملية؛ وبعد الاستقرار يمكنك التباطؤ أعمق أو الانتقال إلى «الاختراق»." + }, + "breath_proto_presence_mid_session_hint": { + "en": "15 minutes once you are using Mid as your starter.", + "ar": "15 دقيقة متى صار «المتوسط» بدايتك." + }, + "breath_proto_presence_mid_subtitle": { + "en": "CCB · ~2.4s / 2.4s · ~12.4 br/min", + "ar": "‏CCB · ~2.4 ث / 2.4 ث · ~12.4 نفَس/دقيقة" + }, + "breath_proto_presence_mid_title": { + "en": "Presence Mid", + "ar": "الحضور المتوسط" + }, + "breath_proto_presence_punching_caution": { + "en": "Not recommended as a beginner default unless the goal is to overcome drowsiness.", + "ar": "لا يُنصح به كخيار افتراضي للمبتدئين إلا إذا كان الهدف تجاوز النعاس." + }, + "breath_proto_presence_punching_edu": { + "en": "Punching Through (~1.9s / 1.9s) is for sleepiness or feeling stuck — not the beginner default. After you are “in it”, you may return to a slower, deeper connected breath.", + "ar": "«الاختراق» (~1.9 ث / 1.9 ث) للنعاس أو الشعور بالجمود — وليس الخيار الافتراضي للمبتدئين. بعد أن تدخل في الحالة، يمكنك العودة إلى نفَس متصل أبطأ وأعمق." + }, + "breath_proto_presence_punching_session_hint": { + "en": "Up to 15 minutes; ease off if strained.", + "ar": "حتى 15 دقيقة؛ خفّف عند الإجهاد." + }, + "breath_proto_presence_punching_subtitle": { + "en": "CCB · ~1.9s / 1.9s · push-through", + "ar": "‏CCB · ~1.9 ث / 1.9 ث · اختراق" + }, + "breath_proto_presence_punching_title": { + "en": "Presence Punching", + "ar": "حضور الاختراق" + }, + "breath_proto_presence_regular_edu": { + "en": "Regular tempo (~3.8s in / 3.8s out) is the default sustainable pace for a first pass and for staying the full 15 minutes.", + "ar": "الإيقاع العادي (~3.8 ث شهيق / 3.8 ث زفير) هو الوتيرة المستدامة الافتراضية للمرة الأولى وللبقاء طوال الخمس عشرة دقيقة." + }, + "breath_proto_presence_regular_session_hint": { + "en": "15 minutes, twice daily (Presence Process frame).", + "ar": "15 دقيقة مرتين يوميًا (إطار عملية الحضور)." + }, + "breath_proto_presence_regular_subtitle": { + "en": "CCB · ~3.8s / 3.8s · ~7.8 br/min", + "ar": "‏CCB · ~3.8 ث / 3.8 ث · ~7.8 نفَس/دقيقة" + }, + "breath_proto_presence_regular_title": { + "en": "Presence Regular", + "ar": "الحضور العادي" + }, + "breath_proto_qigong_edu": { + "en": "Gentle Qi Gong-style pacing: inhale ~4.5s, optional short hold, exhale ~4.5s, optional short hold. Soft belly focus; no force.", + "ar": "إيقاع لطيف بأسلوب تشي غونغ: شهيق ~4.5 ث، حبس قصير اختياري، زفير ~4.5 ث، حبس قصير اختياري. تركيز بطني ليّن؛ دون إجبار." + }, + "breath_proto_qigong_session_hint": { + "en": "15–30 minutes is traditional; start shorter.", + "ar": "15–30 دقيقة تقليديًا؛ ابدأ بأقصر." + }, + "breath_proto_qigong_subtitle": { + "en": "Slow in · soft holds · slow out", + "ar": "شهيق بطيء · حبس ليّن · زفير بطيء" + }, + "breath_proto_qigong_title": { + "en": "Qi Gong Breath", + "ar": "نفَس تشي غونغ" + }, + "breath_proto_relax_4_6_edu": { + "en": "A simple calming pace: inhale for 4 seconds, exhale for 6. The longer exhale tends to favour a rest-and-digest feel. Use it when you want an easy downshift without holds.", + "ar": "وتيرة تهدئة بسيطة: شهيق 4 ثوانٍ، زفير 6. الزفير الأطول يميل إلى تعزيز إحساس الراحة والهضم. استخدمها حين تريد تهدئة سهلة دون حبس." + }, + "breath_proto_relax_4_6_session_hint": { + "en": "Try 5–10 minutes.", + "ar": "جرّب 5–10 دقائق." + }, + "breath_proto_relax_4_6_subtitle": { + "en": "Long exhale · downshift to rest", + "ar": "زفير طويل · تهدئة نحو الراحة" + }, + "breath_proto_relax_4_6_title": { + "en": "Relax 4-6", + "ar": "استرخاء 4-6" + }, + "breath_proto_shamanic_caution": { + "en": "Prefer guided settings for intense practice.", + "ar": "يُفضَّل الإعداد الموجّه للممارسة المكثفة." + }, + "breath_proto_shamanic_edu": { + "en": "Shamanic-style rhythmic breathing is context-dependent and often facilitated. Use this as a quiet timer with education only — set your own safe intensity.", + "ar": "التنفس الإيقاعي بأسلوب شاماني يعتمد على السياق ويُدار عادة بإشراف. استخدم هذا كمؤقّت هادئ مع شرح فقط — واضبط شدة آمنة بنفسك." + }, + "breath_proto_shamanic_session_hint": { + "en": "Pick a length that feels safe.", + "ar": "اختر مدة تشعر أنها آمنة." + }, + "breath_proto_shamanic_subtitle": { + "en": "Guided · rhythmic rapid", + "ar": "موجّه · إيقاعي سريع" + }, + "breath_proto_shamanic_title": { + "en": "Shamanic Breath", + "ar": "نفَس شاماني" + }, + "breath_proto_soma_edu": { + "en": "Simple rhythmic connected pacing at 2 seconds in and 2 seconds out — useful when you want a steady musical-feel loop without holds.", + "ar": "إيقاع متصل بسيط بثانيتين شهيقًا وثانيتين زفيرًا — مفيد حين تريد حلقة ثابتة ذات إحساس موسيقي دون حبس." + }, + "breath_proto_soma_session_hint": { + "en": "Often 20+ minutes; pick a length that fits.", + "ar": "غالبًا 20 دقيقة أو أكثر؛ اختر مدة تناسبك." + }, + "breath_proto_soma_subtitle": { + "en": "2s in · 2s out · rhythmic", + "ar": "ثانيتان شهيق · ثانيتان زفير · إيقاعي" + }, + "breath_proto_tummo_caution": { + "en": "Advanced. Avoid if pregnant, cardiovascular issues, or unwell. Not medical advice.", + "ar": "متقدّم. تجنّبه أثناء الحمل أو مع مشكلات قلبية وعائية أو عند التوعك. ليس نصيحة طبية." + }, + "breath_proto_tummo_edu": { + "en": "A paced Tummo-inspired cycle using mid-range timings: short forced exhale, empty hold, slow inhale, full hold, slow exhale. Advanced and intense — keep sessions short and stop if strained.", + "ar": "دورة مستوحاة من التومو بتوقيتات متوسطة: زفير قسري قصير، حبس فارغ، شهيق بطيء، حبس ممتلئ، زفير بطيء. متقدّمة وشديدة — أبقِ الجلسات قصيرة وتوقف عند الإجهاد." + }, + "breath_proto_tummo_session_hint": { + "en": "5–10 minutes unless experienced.", + "ar": "5–10 دقائق ما لم تكن متمرسًا." + }, + "breath_proto_tummo_subtitle": { + "en": "Forced exhale · holds · slow in/out", + "ar": "زفير قسري · حبس · شهيق/زفير بطيء" + }, + "breath_proto_tummo_title": { + "en": "Tummo", + "ar": "تومو" + }, + "breath_proto_ujjayi_edu": { + "en": "Ujjayi (ocean) breath: slightly constrict the throat so the breath sounds like distant waves. Equal slow inhale and exhale through the nose (~4.5s each).", + "ar": "نفَس أوجايي (المحيط): ضيّق الحلق قليلًا حتى يبدو النفَس كأمواج بعيدة. شهيق وزفير بطيئان متساويان عبر الأنف (~4.5 ث لكل منهما)." + }, + "breath_proto_ujjayi_session_hint": { + "en": "5–15 minutes.", + "ar": "5–15 دقيقة." + }, + "breath_proto_ujjayi_subtitle": { + "en": "Ocean breath · soft throat", + "ar": "نفَس المحيط · حلق ليّن" + }, + "breath_proto_ujjayi_title": { + "en": "Ujjayi", + "ar": "أوجايي" + }, + "breath_proto_wim_hof_caution": { + "en": "Never practice in water or while driving. Stop if dizzy. Not medical advice.", + "ar": "لا تمارسه أبدًا في الماء أو أثناء القيادة. توقف عند الدوار. ليس نصيحة طبية." + }, + "breath_proto_wim_hof_edu": { + "en": "Wim Hof–style rounds typically use ~30 deeper breaths, then an empty hold, then a recovery inhale hold (~15s). Because holds and intensity are personal, this entry is guided: follow a trusted protocol you already know; NOOP times the session and shows reminders — it does not force breath holds.", + "ar": "جولات بأسلوب فيم هوف تستخدم عادة نحو 30 نفَسًا أعمق، ثم حبسًا فارغًا، ثم حبس شهيق للتعافي (~15 ث). ولأن الحبس والشدة أمران شخصيان، هذا المدخل موجّه: اتبع بروتوكولًا موثوقًا تعرفه؛ يوقّت NOOP الجلسة ويعرض التذكيرات — ولا يفرض حبس النفس." + }, + "breath_proto_wim_hof_session_hint": { + "en": "About 15 minutes for a few rounds.", + "ar": "نحو 15 دقيقة لبضع جولات." + }, + "breath_proto_wim_hof_subtitle": { + "en": "Guided · rounds + holds", + "ar": "موجّه · جولات + حبس" + }, + "breath_proto_wim_hof_title": { + "en": "Wim Hof", + "ar": "فيم هوف" + }, + "breath_stage_belly_in": { + "en": "Belly in", + "ar": "البطن للداخل" + }, + "breath_stage_belly_out": { + "en": "Belly out", + "ar": "البطن للخارج" + }, + "breath_stage_exhale_left": { + "en": "Exhale left", + "ar": "زفير يسار" + }, + "breath_stage_exhale_right": { + "en": "Exhale right", + "ar": "زفير يمين" + }, + "breath_stage_force_out": { + "en": "Force out", + "ar": "زفير قسري" + }, + "breath_stage_hold_empty": { + "en": "Hold empty", + "ar": "حبس فارغ" + }, + "breath_stage_hold_full": { + "en": "Hold full", + "ar": "حبس ممتلئ" + }, + "breath_stage_inhale_left": { + "en": "Inhale left", + "ar": "شهيق يسار" + }, + "breath_stage_inhale_right": { + "en": "Inhale right", + "ar": "شهيق يمين" + }, + "crash_recovery_body": { + "en": "The last crash is shown below. You can copy it for a bug report, then try opening the app again.", + "ar": "يظهر آخر تعطل أدناه. يمكنك نسخه لتقرير خطأ، ثم محاولة فتح التطبيق مرة أخرى." + }, + "crash_recovery_continue": { + "en": "Try opening NOOP", + "ar": "محاولة فتح NOOP" + }, + "crash_recovery_copy": { + "en": "Copy error", + "ar": "نسخ الخطأ" + }, + "crash_recovery_title": { + "en": "NOOP stopped unexpectedly", + "ar": "توقف NOOP بشكل غير متوقع" + }, + "cycle_day_range": { + "en": "· ~day %1$d–%2$d", + "ar": "· ~اليوم %1$d–%2$d" + }, + "cycle_day_single": { + "en": "· ~day %1$d", + "ar": "· ~اليوم %1$d" + }, + "cycle_home_description": { + "en": "Log period starts locally and use them to anchor optional cycle estimates.", + "ar": "سجّل بدايات الدورة محليًا واستخدمها كمرجع لتقديرات الدورة الاختيارية." + }, + "cycle_home_latest": { + "en": "Latest period start", + "ar": "آخر بداية دورة" + }, + "cycle_home_learning": { + "en": "Learning your pattern", + "ar": "جارٍ تعلّم نمطك" + }, + "cycle_home_log_today": { + "en": "Log today", + "ar": "تسجيل اليوم" + }, + "cycle_home_logged_today": { + "en": "Logged today", + "ar": "سُجِّل اليوم" + }, + "cycle_home_open": { + "en": "Open tracker", + "ar": "فتح المتتبّع" + }, + "cycle_home_private": { + "en": "Private, on-device tracking", + "ar": "تتبّع خاص على الجهاز" + }, + "cycle_home_setup": { + "en": "Set up cycle tracking", + "ar": "إعداد تتبّع الدورة" + }, + "cycle_home_title": { + "en": "Menstrual Cycle", + "ar": "الدورة الشهرية" + }, + "cycle_phase_follicular": { + "en": "Follicular", + "ar": "الطور الجريبي" + }, + "cycle_phase_learning": { + "en": "Learning your pattern", + "ar": "جارٍ تعلّم نمطك" + }, + "cycle_phase_luteal": { + "en": "Luteal", + "ar": "الطور الأصفري" + }, + "cycle_phase_mid_cycle": { + "en": "Mid-cycle shift", + "ar": "تحوّل منتصف الدورة" + }, + "cycle_phase_unclear": { + "en": "No clear pattern", + "ar": "لا نمط واضح" + }, + "cycle_tracker_already_logged": { + "en": "Already logged", + "ar": "مُسجَّل بالفعل" + }, + "cycle_tracker_anchor_help": { + "en": "This optional date anchors cycle day 1 and is checked against your nightly temperature pattern.", + "ar": "هذا التاريخ الاختياري يحدّد اليوم الأول من الدورة ويُقارن بنمط حرارتك الليلية." + }, + "cycle_tracker_cancel": { + "en": "Cancel", + "ar": "إلغاء" + }, + "cycle_tracker_current_estimate": { + "en": "Current estimate", + "ar": "التقدير الحالي" + }, + "cycle_tracker_day_range": { + "en": "~day %1$d–%2$d", + "ar": "~اليوم %1$d–%2$d" + }, + "cycle_tracker_day_single": { + "en": "~day %1$d", + "ar": "~اليوم %1$d" + }, + "cycle_tracker_delete_all": { + "en": "Delete all", + "ar": "حذف الكل" + }, + "cycle_tracker_delete_day": { + "en": "Delete %1$s", + "ar": "حذف %1$s" + }, + "cycle_tracker_delete_message": { + "en": "This permanently removes the on-device period-start history. Sensor history is not changed.", + "ar": "يزيل هذا نهائيًا سجل بدايات الدورة المخزّن على الجهاز. لا يتغير سجل المستشعرات." + }, + "cycle_tracker_delete_title": { + "en": "Delete all logged period starts?", + "ar": "حذف كل بدايات الدورة المسجّلة؟" + }, + "cycle_tracker_done": { + "en": "Done", + "ar": "تم" + }, + "cycle_tracker_empty": { + "en": "No period starts logged yet.", + "ar": "لم تُسجَّل أي بدايات دورة بعد." + }, + "cycle_tracker_likely_window": { + "en": "Likely period window: %1$s–%2$s. This is a range, not a fixed date.", + "ar": "نافذة الدورة المرجّحة: %1$s–%2$s. هذا نطاق وليس تاريخًا محددًا." + }, + "cycle_tracker_log_action": { + "en": "Log period start", + "ar": "تسجيل بداية دورة" + }, + "cycle_tracker_log_heading": { + "en": "Log a period start", + "ar": "سجّل بداية دورة" + }, + "cycle_tracker_logged_starts": { + "en": "Logged starts", + "ar": "البدايات المسجّلة" + }, + "cycle_tracker_privacy": { + "en": "This never leaves your device unless you export a backup yourself.", + "ar": "لا يغادر هذا جهازك أبدًا ما لم تُصدّر نسخة احتياطية بنفسك." + }, + "cycle_tracker_title": { + "en": "Cycle tracker", + "ar": "متتبّع الدورة" + }, + "deep_timeline_title": { + "en": "Deep Timeline", + "ar": "الجدول الزمني العميق" + }, + "explore_all_history": { + "en": "all history", + "ar": "كل السجل" + }, + "explore_average": { + "en": "Average", + "ar": "المتوسط" + }, + "explore_category_charge": { + "en": "Charge", + "ar": "الشحن" + }, + "explore_category_effort": { + "en": "Effort", + "ar": "الجهد" + }, + "explore_category_health": { + "en": "Health", + "ar": "الصحة" + }, + "explore_category_heart": { + "en": "Heart", + "ar": "القلب" + }, + "explore_category_mind": { + "en": "Mind", + "ar": "الذهن" + }, + "explore_category_nutrition": { + "en": "Nutrition", + "ar": "التغذية" + }, + "explore_category_rest": { + "en": "Rest", + "ar": "الراحة" + }, + "explore_chart_points": { + "en": "Points", + "ar": "النقاط" + }, + "explore_chart_window": { + "en": "Window", + "ar": "النافذة" + }, + "explore_deep_timeline_hint": { + "en": "Every second of your day, zoomable.", + "ar": "كل ثانية من يومك، قابلة للتكبير." + }, + "explore_delta_vs_prev": { + "en": "Δ vs prev", + "ar": "Δ مقارنة بالسابق" + }, + "explore_description_charge": { + "en": "How recovered you are, led by HRV versus your personal baseline.", + "ar": "مدى تعافيك، يقوده تقلب النبض مقارنةً بخط الأساس الخاص بك." + }, + "explore_description_effort": { + "en": "Cardiovascular load for the day, on a 0-100 scale (was 0-21).", + "ar": "الحمل القلبي الوعائي لليوم، على مقياس 0-100 (كان 0-21)." + }, + "explore_description_rest": { + "en": "How restorative your sleep was: duration, efficiency, deep+REM, timing.", + "ar": "مدى استعادة نومك: المدة، والكفاءة، والنوم العميق وREM، والتوقيت." + }, + "explore_empty_metric": { + "en": "No %1$s recorded yet. Sync your strap to populate this trend.", + "ar": "لم يُسجَّل %1$s بعد. زامن سوارك لتعبئة هذا الاتجاه." + }, + "explore_import_body": { + "en": "Import your history first. A WHOOP export in Data Sources fills every metric you can explore here in about a minute.", + "ar": "استورد سجلك أولًا. ملف تصدير WHOOP في «مصادر البيانات» يملأ كل مقياس يمكنك استكشافه هنا في نحو دقيقة." + }, + "explore_import_title": { + "en": "Import your history first", + "ar": "استورد سجلك أولًا" + }, + "explore_latest": { + "en": "Latest", + "ar": "الأحدث" + }, + "explore_max": { + "en": "Max", + "ar": "الأقصى" + }, + "explore_metric_average_heart_rate": { + "en": "Average Heart Rate", + "ar": "متوسط معدل النبض" + }, + "explore_metric_calories_in": { + "en": "Calories In", + "ar": "السعرات المتناولة" + }, + "explore_metric_carbs": { + "en": "Carbs", + "ar": "الكربوهيدرات" + }, + "explore_metric_fat": { + "en": "Fat", + "ar": "الدهون" + }, + "explore_metric_max_heart_rate": { + "en": "Max Heart Rate", + "ar": "أقصى معدل نبض" + }, + "explore_metric_mood": { + "en": "Mood", + "ar": "المزاج" + }, + "explore_metric_protein": { + "en": "Protein", + "ar": "البروتين" + }, + "explore_min": { + "en": "Min", + "ar": "الأدنى" + }, + "explore_n_pts": { + "en": "%1$d pts", + "ar": "%1$d نقطة" + }, + "explore_no_prior_window": { + "en": "no prior %1$s", + "ar": "لا %1$s سابقة" + }, + "explore_over_visible_window": { + "en": "Over the visible window", + "ar": "خلال النافذة الظاهرة" + }, + "explore_pick_metric": { + "en": "Pick metric", + "ar": "اختر مقياسًا" + }, + "explore_single_reading": { + "en": "Only one reading in range — widen the window to see a trend.", + "ar": "قراءة واحدة فقط في النطاق — وسّع النافذة لرؤية اتجاه." + }, + "explore_subtitle": { + "en": "Every signal, one tap deep.", + "ar": "كل إشارة، على بُعد نقرة واحدة." + }, + "explore_summary": { + "en": "Summary", + "ar": "الملخص" + }, + "explore_vs_prev_window": { + "en": "vs prev %1$s", + "ar": "مقارنةً بـ %1$s السابقة" + }, + "fast_history_sync": { + "en": "Faster history sync (experimental)", + "ar": "مزامنة سجل أسرع (تجريبي)" + }, + "fast_history_sync_desc": { + "en": "Ask Android for a faster Bluetooth link while your strap hands over its stored history, so a big backlog catches up sooner. Only during the sync itself — your live heart rate and the overnight stream are untouched. New and unproven: if it doesn\\'t help, or costs more battery than it\\'s worth, turn it back off and say so on the issue tracker.", + "ar": "يطلب من Android اتصال Bluetooth أسرع بينما يسلّم سوارك سجله المخزّن، حتى يلحق التراكم الكبير أسرع. أثناء المزامنة نفسها فقط — لا يتأثر نبضك المباشر ولا البث الليلي. جديدة وغير مثبتة: إن لم تساعد أو كلّفت بطارية أكثر مما تستحق، أعد إيقافها وأخبرنا في متتبّع المشكلات." + }, + "fast_link_phy": { + "en": "Faster Bluetooth link (experimental)", + "ar": "اتصال Bluetooth أسرع (تجريبي)" + }, + "fast_link_phy_desc": { + "en": "Ask for a faster Bluetooth radio mode while your strap hands over its stored history. Sends the same data in less airtime, so it may sync sooner and use a little less strap battery — but your strap has to support it, and it trades some range for speed. New and unproven: if syncing gets flaky when your phone is further away, turn it back off.", + "ar": "يطلب وضع راديو Bluetooth أسرع بينما يسلّم سوارك سجله المخزّن. يرسل البيانات نفسها في وقت بث أقل، فقد تتم المزامنة أسرع وتوفّر قليلًا من بطارية السوار — لكن على سوارك أن يدعمه، وهو يقايض بعض المدى بالسرعة. جديدة وغير مثبتة: إن صارت المزامنة غير مستقرة حين يبتعد هاتفك، أعد إيقافها." + }, + "fgs_action_disconnect": { + "en": "Disconnect", + "ar": "قطع الاتصال" + }, + "fgs_channel_desc": { + "en": "Shown while NOOP keeps your WHOOP connected in the background.", + "ar": "يظهر بينما يُبقي NOOP اتصال WHOOP في الخلفية." + }, + "fgs_channel_name": { + "en": "Strap connection", + "ar": "اتصال السوار" + }, + "fgs_detail_battery": { + "en": "Strap %1$d%%", + "ar": "السوار %1$d%%" + }, + "fgs_detail_keeping_link": { + "en": "Keeping the link open", + "ar": "إبقاء الاتصال مفتوحًا" + }, + "fgs_detail_recovery": { + "en": "Recovery %1$d%%", + "ar": "التعافي %1$d%%" + }, + "fgs_detail_streaming": { + "en": "Streaming in the background", + "ar": "بث في الخلفية" + }, + "fgs_title_connected": { + "en": "Connected to your WHOOP", + "ar": "متصل بـ WHOOP" + }, + "fgs_title_reconnecting": { + "en": "Reconnecting to your WHOOP…", + "ar": "إعادة الاتصال بـ WHOOP…" + }, + "fgs_title_syncing": { + "en": "Syncing strap history…", + "ar": "مزامنة سجل السوار…" + }, + "ground_truth_add_historical": { + "en": "Add past time window", + "ar": "إضافة نافذة زمنية سابقة" + }, + "ground_truth_add_marker": { + "en": "Add marker", + "ar": "إضافة علامة" + }, + "ground_truth_band_connected_paired": { + "en": "Band: connected + paired", + "ar": "السوار: متصل + مقترن" + }, + "ground_truth_band_connected_pairing": { + "en": "Band: connected; pairing", + "ar": "السوار: متصل؛ جارٍ الاقتران" + }, + "ground_truth_band_disconnected": { + "en": "Band: disconnected", + "ar": "السوار: غير متصل" + }, + "ground_truth_band_searching": { + "en": "Band: disconnected; searching", + "ar": "السوار: غير متصل؛ جارٍ البحث" + }, + "ground_truth_cancel": { + "en": "Cancel", + "ar": "إلغاء" + }, + "ground_truth_capture_status": { + "en": "Capture status", + "ar": "حالة التسجيل" + }, + "ground_truth_comment": { + "en": "Session comment", + "ar": "تعليق الجلسة" + }, + "ground_truth_coverage": { + "en": "%1$d/%2$d s · %3$.1f%% · %4$d s missing%5$s", + "ar": "%1$d/%2$d ث · %3$.1f%% · %4$d ث مفقودة%5$s" + }, + "ground_truth_coverage_startup": { + "en": " · %1$d s startup", + "ar": " · %1$d ث بدء" + }, + "ground_truth_delete_all": { + "en": "Delete all sessions", + "ar": "حذف كل الجلسات" + }, + "ground_truth_delete_all_message": { + "en": "All %1$d recorded sessions and their captured raw data will be deleted permanently.", + "ar": "ستُحذف نهائيًا كل الجلسات المسجّلة البالغة %1$d وبياناتها الخام." + }, + "ground_truth_delete_all_title": { + "en": "Delete all sessions?", + "ar": "حذف كل الجلسات؟" + }, + "ground_truth_delete_confirm": { + "en": "Delete", + "ar": "حذف" + }, + "ground_truth_delete_session": { + "en": "Delete session", + "ar": "حذف الجلسة" + }, + "ground_truth_delete_session_message": { + "en": "The session %1$s and its captured raw data will be deleted permanently.", + "ar": "ستُحذف الجلسة %1$s وبياناتها الخام نهائيًا." + }, + "ground_truth_delete_session_title": { + "en": "Delete this session?", + "ar": "حذف هذه الجلسة؟" + }, + "ground_truth_edit_end": { + "en": "Edit end", + "ar": "تعديل النهاية" + }, + "ground_truth_edit_marker": { + "en": "Edit marker", + "ar": "تعديل العلامة" + }, + "ground_truth_edit_start": { + "en": "Edit start", + "ar": "تعديل البداية" + }, + "ground_truth_export": { + "en": "Export session", + "ar": "تصدير الجلسة" + }, + "ground_truth_export_failed": { + "en": "Couldn’t export session: %1$s", + "ar": "تعذّر تصدير الجلسة: %1$s" + }, + "ground_truth_exporting": { + "en": "Building export…", + "ar": "جارٍ إنشاء التصدير…" + }, + "ground_truth_history_completed": { + "en": "History sync completed: %1$s", + "ar": "اكتملت مزامنة السجل: %1$s" + }, + "ground_truth_history_none": { + "en": "History sync: no completed sync recorded", + "ar": "مزامنة السجل: لا توجد مزامنة مكتملة مسجّلة" + }, + "ground_truth_history_running": { + "en": "History sync: running (%1$d chunks)", + "ar": "مزامنة السجل: جارية (%1$d أجزاء)" + }, + "ground_truth_last_exported": { + "en": "Last exported %1$s · export remains available", + "ar": "آخر تصدير %1$s · التصدير ما زال متاحًا" + }, + "ground_truth_legacy_no_sensors": { + "en": "Legacy session: event export is available, but its strap cannot be identified for sensor export.", + "ar": "جلسة قديمة: تصدير الأحداث متاح، لكن يتعذّر تحديد سوارها لتصدير المستشعرات." + }, + "ground_truth_marker_end": { + "en": "End", + "ar": "نهاية" + }, + "ground_truth_marker_issue": { + "en": "Issue", + "ar": "مشكلة" + }, + "ground_truth_marker_moment": { + "en": "Moment", + "ar": "لحظة" + }, + "ground_truth_marker_note": { + "en": "Marker note", + "ar": "ملاحظة العلامة" + }, + "ground_truth_marker_start": { + "en": "Start", + "ar": "بداية" + }, + "ground_truth_marker_times": { + "en": "Marker %1$s · Now %2$s", + "ar": "العلامة %1$s · الآن %2$s" + }, + "ground_truth_no_sessions": { + "en": "No sessions recorded yet.", + "ar": "لم تُسجَّل أي جلسات بعد." + }, + "ground_truth_open": { + "en": "Open raw-data collector", + "ar": "فتح جامع البيانات الخام" + }, + "ground_truth_ready_complete": { + "en": "Ready to export · complete", + "ar": "جاهز للتصدير · مكتمل" + }, + "ground_truth_ready_missing": { + "en": "Ready with %1$d s missing · history may repair it", + "ar": "جاهز مع %1$d ث مفقودة · قد يصلحها السجل" + }, + "ground_truth_ready_no_imu": { + "en": "Ready without IMU data", + "ar": "جاهز دون بيانات IMU" + }, + "ground_truth_realtime_imu": { + "en": "Realtime IMU: %1$s; %2$d packets / %3$d bytes%4$s", + "ar": "‏IMU مباشر: %1$s؛ %2$d حزمة / %3$d بايت%4$s" + }, + "ground_truth_realtime_last": { + "en": "; last %1$s ago", + "ar": "؛ آخرها قبل %1$s" + }, + "ground_truth_recording": { + "en": "Recording · %1$s", + "ar": "تسجيل · %1$s" + }, + "ground_truth_save": { + "en": "Save", + "ar": "حفظ" + }, + "ground_truth_sensors_behind": { + "en": "Decoded 1 Hz sensors: %1$s behind", + "ar": "مستشعرات 1 هرتز المفكوكة: متأخرة %1$s" + }, + "ground_truth_sensors_none": { + "en": "Decoded 1 Hz sensors: none", + "ar": "مستشعرات 1 هرتز المفكوكة: لا شيء" + }, + "ground_truth_sessions": { + "en": "Recorded sessions", + "ar": "الجلسات المسجّلة" + }, + "ground_truth_start": { + "en": "Start raw-data session", + "ar": "بدء جلسة بيانات خام" + }, + "ground_truth_stop": { + "en": "Stop session", + "ar": "إيقاف الجلسة" + }, + "ground_truth_subtitle": { + "en": "Record a bounded 100 Hz motion session, recover Bluetooth gaps from strap history, and export the complete timeline.", + "ar": "سجّل جلسة حركة محدودة بتردد 100 هرتز، واسترجع فجوات Bluetooth من سجل السوار، وصدّر الجدول الزمني الكامل." + }, + "ground_truth_test_centre_desc": { + "en": "Record, annotate, recover, and export a bounded 5/MG 100 Hz motion capture.", + "ar": "تسجيل وتوصيف واسترجاع وتصدير التقاط حركة محدود بتردد 100 هرتز لـ 5/MG." + }, + "ground_truth_title": { + "en": "5/MG Raw Data Collector", + "ar": "جامع البيانات الخام 5/MG" + }, + "ground_truth_waiting_history": { + "en": "Waiting for history through %1$s", + "ar": "في انتظار السجل حتى %1$s" + }, + "ground_truth_waiting_imu": { + "en": "waiting for IMU", + "ar": "في انتظار IMU" + }, + "haptics_breathing_label": { + "en": "Breathing pacer", + "ar": "موجّه إيقاع التنفس" + }, + "haptics_intervals_label": { + "en": "Interval timer", + "ar": "مؤقّت الفترات" + }, + "haptics_live_session_label": { + "en": "Live Session cues", + "ar": "تنبيهات الجلسة المباشرة" + }, + "haptics_section_title": { + "en": "Haptics", + "ar": "الاهتزاز" + }, + "haptics_workout_label": { + "en": "Workout start & end", + "ar": "بداية التمرين ونهايته" + }, + "health_connect_categories_detail": { + "en": "Select the Health Connect categories to import. You can change this later.", + "ar": "اختر فئات Health Connect المراد استيرادها. يمكنك تغيير ذلك لاحقًا." + }, + "health_connect_categories_title": { + "en": "Choose what NOOP can read", + "ar": "اختر ما يمكن لـ NOOP قراءته" + }, + "health_connect_category_activity": { + "en": "Activity", + "ar": "النشاط" + }, + "health_connect_category_activity_detail": { + "en": "Steps, calories, workouts, distance, and VO₂ max", + "ar": "الخطوات والسعرات والتمارين والمسافة وVO₂ max" + }, + "health_connect_category_body_composition": { + "en": "Body composition", + "ar": "تركيب الجسم" + }, + "health_connect_category_body_composition_detail": { + "en": "Weight, body fat, and lean mass", + "ar": "الوزن ونسبة الدهون والكتلة الصافية" + }, + "health_connect_category_recovery": { + "en": "Recovery & wellness", + "ar": "التعافي والعافية" + }, + "health_connect_category_recovery_detail": { + "en": "Heart rate, HRV, sleep, SpO₂, breathing, and hydration", + "ar": "معدل النبض وتقلب النبض والنوم وSpO₂ والتنفس والترطيب" + }, + "illness_alert_title": { + "en": "Early warning: take it easy", + "ar": "إنذار مبكر: خفّف من نشاطك" + }, + "inactivity_title": { + "en": "Move reminder", + "ar": "تذكير بالحركة" + }, + "intervals_range_step": { + "en": "%1$s – %2$s%3$s · step %4$d", + "ar": "%1$s – %2$s%3$s · خطوة %4$d" + }, + "keep_alive_needed_vendor": { + "en": "Your phone (%1$s) can stop background apps to save battery, which can make NOOP miss overnight sleep and recovery data. Tap Allow to whitelist NOOP.\\n\\nAndroid will ask “Stop optimising battery usage?” and warn that NOOP may drain your battery — that\\'s its standard wording for any app allowed to run in the background. Tap Allow. The battery goes on the overnight sync you already switched on above; this only stops Android cutting it short.", + "ar": "قد يوقف هاتفك (%1$s) التطبيقات العاملة في الخلفية لتوفير البطارية، مما قد يجعل NOOP يفوّت بيانات النوم والتعافي الليلية. اضغط «سماح» لإضافة NOOP إلى الاستثناءات.\\n\\nسيسأل Android «إيقاف تحسين استخدام البطارية؟» وينبّه إلى أن NOOP قد يستنزف البطارية — وهذه صياغته المعتادة لأي تطبيق يُسمح له بالعمل في الخلفية. اضغط «سماح». تُستهلك البطارية في المزامنة الليلية التي فعّلتها بالفعل أعلاه؛ وهذا يمنع Android فقط من قطعها." + }, + "l10n_add_device_wizard_a_whoop_strap_bonds_to_a_d39bdf4a": { + "en": "A WHOOP strap bonds to a single device. While it\\'s connected to NOOP it won\\'t stream ", + "ar": "يقترن سوار WHOOP بجهاز واحد فقط. وأثناء اتصاله بـ NOOP لن يبثّ " + }, + "l10n_add_device_wizard_add_61cc55aa": { + "en": "Add", + "ar": "إضافة" + }, + "l10n_add_device_wizard_advanced_i_already_have_my_ring_e87b9b49": { + "en": "Advanced: I already have my ring\\'s key", + "ar": "متقدّم: لديّ مفتاح خاتمي بالفعل" + }, + "l10n_add_device_wizard_back_b52b36b7": { + "en": "Back", + "ar": "رجوع" + }, + "l10n_add_device_wizard_beta_is_an_on_device_estimate_128563e0": { + "en": "Beta. * is an on-device estimate. Skin temp is a trend versus your own baseline, steps ", + "ar": "إصدار تجريبي. * تقدير على الجهاز. حرارة الجلد اتجاه مقارنةً بخط الأساس الخاص بك، والخطوات " + }, + "l10n_add_device_wizard_both_noop_and_the_oura_app_4f219db3": { + "en": "Both NOOP and the Oura app can use a ring you own by key, but only one can hold the Bluetooth link at a time.", + "ar": "يمكن لكل من NOOP وتطبيق Oura استخدام خاتم تملكه عبر المفتاح، لكن واحدًا فقط يمكنه الاحتفاظ باتصال Bluetooth في كل مرة." + }, + "l10n_add_device_wizard_cancel_77dfd213": { + "en": "Cancel", + "ar": "إلغاء" + }, + "l10n_add_device_wizard_close_bbfa773e": { + "en": "Close", + "ar": "إغلاق" + }, + "l10n_add_device_wizard_connect_to_this_ring_02b9442b": { + "en": "Connect to this ring", + "ar": "الاتصال بهذا الخاتم" + }, + "l10n_add_device_wizard_continue_2e026239": { + "en": "Continue", + "ar": "متابعة" + }, + "l10n_add_device_wizard_device_name_79d7a157": { + "en": "Device name", + "ar": "اسم الجهاز" + }, + "l10n_add_device_wizard_experimental_best_effort_support_we_re_db288aa8": { + "en": "Experimental, best-effort support. We\\'re still testing these, so they might not connect on every device. They never make up data, and they\\'ll tell you honestly when live isn\\'t possible.", + "ar": "دعم تجريبي بأفضل جهد ممكن. ما زلنا نختبرها، لذا قد لا تتصل على كل جهاز. لا تختلق البيانات أبدًا، وستخبرك بصدق حين يتعذّر البث المباشر." + }, + "l10n_add_device_wizard_i_understand_this_disconnects_the_ring_66bbb125": { + "en": "I understand this disconnects the ring from Oura and that NOOP cannot undo it for me. To go back to Oura I would factory-reset the ring again and set it up in the Oura app.", + "ar": "أفهم أن هذا يفصل الخاتم عن Oura وأن NOOP لا يستطيع التراجع عن ذلك نيابةً عني. وللعودة إلى Oura سأعيد ضبط الخاتم لإعدادات المصنع وأعدّه في تطبيق Oura." + }, + "l10n_add_device_wizard_i_understand_this_disconnects_the_ring_ccbb3225": { + "en": "I understand this disconnects the ring from Oura and that NOOP cannot undo it for me.", + "ar": "أفهم أن هذا يفصل الخاتم عن Oura وأن NOOP لا يستطيع التراجع عن ذلك نيابةً عني." + }, + "l10n_add_device_wizard_installing_noop_s_key_and_confirming_aab5cf61": { + "en": "Installing NOOP\\'s key and confirming the ring answers only to NOOP. Keep the ring close and do not open the Oura app.", + "ar": "جارٍ تثبيت مفتاح NOOP والتأكد من أن الخاتم يستجيب لـ NOOP وحده. أبقِ الخاتم قريبًا ولا تفتح تطبيق Oura." + }, + "l10n_add_device_wizard_keep_the_oura_app_instead_import_60d70411": { + "en": "Keep the Oura app instead (import a file)", + "ar": "الإبقاء على تطبيق Oura بدلًا من ذلك (استيراد ملف)" + }, + "l10n_add_device_wizard_make_active_75690bb8": { + "en": "Make active", + "ar": "تعيينه نشطًا" + }, + "l10n_add_device_wizard_make_confirmname_your_active_device_now_90ceb73e": { + "en": "Make %1$s your active device now? It will provide your live data. You can change ", + "ar": "تعيين %1$s جهازك النشط الآن؟ سيوفّر بياناتك المباشرة. يمكنك التغيير " + }, + "l10n_add_device_wizard_make_sure_it_s_awake_and_8c40e59f": { + "en": "Make sure it\\'s awake and not connected elsewhere.", + "ar": "تأكد من أنه مستيقظ وغير متصل بمكان آخر." + }, + "l10n_add_device_wizard_make_this_your_active_device_b425485c": { + "en": "Make this your active device?", + "ar": "تعيين هذا جهازك النشط؟" + }, + "l10n_add_device_wizard_name_signal_signalbars_level_rssi_of_6aa514f6": { + "en": "%1$s, signal %2$s of 4", + "ar": "%1$s، الإشارة %2$s من 4" + }, + "l10n_add_device_wizard_noop_stores_this_key_only_on_eae4e63f": { + "en": "NOOP stores this key only on this device, in the same place it stores your paired bands.", + "ar": "يخزّن NOOP هذا المفتاح على هذا الجهاز فقط، في المكان نفسه الذي يخزّن فيه أساورك المقترنة." + }, + "l10n_add_device_wizard_noop_will_install_its_own_key_d3f6321e": { + "en": "NOOP will install its own key on the ring and become its owner. The Oura app will no longer control this ring. This is intended and it cannot be undone from NOOP.", + "ar": "سيثبّت NOOP مفتاحه الخاص على الخاتم ويصبح مالكه. لن يتحكم تطبيق Oura بهذا الخاتم بعد الآن. هذا مقصود ولا يمكن التراجع عنه من NOOP." + }, + "l10n_add_device_wizard_not_now_e4571490": { + "en": "Not now", + "ar": "ليس الآن" + }, + "l10n_add_device_wizard_not_showing_up_make_sure_you_60a8a61b": { + "en": "Not showing up? Make sure you reset the ring in the Oura app and force-quit it, then tap Rescan. A ring still owned by Oura will not list here.", + "ar": "لا يظهر؟ تأكد من إعادة ضبط الخاتم في تطبيق Oura وإغلاق التطبيق تمامًا، ثم اضغط «إعادة البحث». الخاتم الذي ما زالت Oura تملكه لن يظهر هنا." + }, + "l10n_add_device_wizard_one_phone_at_a_time_cff4ff44": { + "en": "One phone at a time", + "ar": "هاتف واحد في كل مرة" + }, + "l10n_add_device_wizard_oura_ring_e3431536": { + "en": "Oura ring", + "ar": "خاتم Oura" + }, + "l10n_add_device_wizard_rescan_84661f6a": { + "en": "Rescan", + "ar": "إعادة البحث" + }, + "l10n_add_device_wizard_ring_key_32_hex_characters_28cb7e4a": { + "en": "Ring key, 32 hex characters", + "ar": "مفتاح الخاتم، 32 رمزًا ست عشريًا" + }, + "l10n_add_device_wizard_scan_28cba55d": { + "en": "Scan", + "ar": "بحث" + }, + "l10n_add_device_wizard_scan_for_type_title_be2c98fb": { + "en": "Scan for %1$s", + "ar": "البحث عن %1$s" + }, + "l10n_add_device_wizard_scan_for_your_ring_e92b3a3b": { + "en": "Scan for your ring", + "ar": "ابحث عن خاتمك" + }, + "l10n_add_device_wizard_searching_1a6a5ba8": { + "en": "Searching…", + "ar": "جارٍ البحث…" + }, + "l10n_add_device_wizard_take_over_2c7f5505": { + "en": "Take over", + "ar": "الاستحواذ" + }, + "l10n_add_device_wizard_take_over_this_ring_cc79ef5e": { + "en": "Take over this ring?", + "ar": "الاستحواذ على هذا الخاتم؟" + }, + "l10n_add_device_wizard_take_over_this_ring_f199dc22": { + "en": "Take over this ring", + "ar": "الاستحواذ على هذا الخاتم" + }, + "l10n_add_device_wizard_taking_over_your_ring_b64a3852": { + "en": "Taking over your ring", + "ar": "جارٍ الاستحواذ على خاتمك" + }, + "l10n_add_device_wizard_that_is_not_a_32_character_d42ca20d": { + "en": "That is not a 32-character hex key.", + "ar": "هذا ليس مفتاحًا ست عشريًا من 32 رمزًا." + }, + "l10n_add_device_wizard_the_ring_is_not_bricked_to_5efd1b3d": { + "en": "The ring is not bricked. To go back to where you started, factory-reset it again and set it ", + "ar": "الخاتم ليس معطلًا. وللعودة إلى ما كنت عليه، أعد ضبطه لإعدادات المصنع مرة أخرى وأعدّه " + }, + "l10n_add_device_wizard_try_again_042c862e": { + "en": "Try again", + "ar": "إعادة المحاولة" + }, + "l10n_add_device_wizard_use_file_import_0e092cb4": { + "en": "Use file import", + "ar": "استخدام استيراد ملف" + }, + "l10n_add_device_wizard_we_could_not_take_over_this_fec1ce93": { + "en": "We could not take over this ring.", + "ar": "تعذّر الاستحواذ على هذا الخاتم." + }, + "l10n_add_device_wizard_whoop_5_0_mg_support_is_e452e686": { + "en": "WHOOP 5.0 / MG support is newer and still experimental in NOOP.", + "ar": "دعم WHOOP 5.0 / MG أحدث وما زال تجريبيًا في NOOP." + }, + "l10n_add_device_wizard_whoop_is_noop_s_primary_fully_ac6fa33b": { + "en": "WHOOP is NOOP\\'s primary, fully-supported band. Other heart-rate straps stream live heart rate and HRV, but not WHOOP\\'s deeper sleep and recovery data.", + "ar": "‏WHOOP هو السوار الأساسي المدعوم بالكامل في NOOP. الأساور الأخرى تبثّ معدل النبض وتقلب النبض مباشرةً، لكن دون بيانات النوم والتعافي الأعمق الخاصة بـ WHOOP." + }, + "l10n_app_changelog_5_mg_bonding_on_android_health_c94c46ac": { + "en": "5/MG bonding on Android + Health Monitor fix", + "ar": "اقتران 5/MG على Android + إصلاح Health Monitor" + }, + "l10n_app_changelog_a_beautiful_new_look_aa8c910c": { + "en": "A beautiful new look", + "ar": "مظهر جديد جميل" + }, + "l10n_app_changelog_a_big_bug_fix_sweep_with_65335764": { + "en": "A big bug-fix sweep, with the Test Centre to back it up", + "ar": "موجة كبيرة من إصلاح الأخطاء، بدعم من مركز الاختبار" + }, + "l10n_app_changelog_a_big_one_smarter_sleep_naps_8d7745c6": { + "en": "A big one: smarter sleep, naps, more devices, and a load of fixes", + "ar": "تحديث كبير: نوم أذكى، وقيلولات، وأجهزة أكثر، وكثير من الإصلاحات" + }, + "l10n_app_changelog_a_bolder_today_screen_4efe8215": { + "en": "A bolder Today screen", + "ar": "شاشة «اليوم» أكثر جرأة" + }, + "l10n_app_changelog_a_calmer_today_your_charge_explained_e4174303": { + "en": "A calmer Today, your Charge explained, and new HRV science under the hood", + "ar": "«اليوم» أهدأ، وشرح للشحن، وعلم جديد لتقلب النبض تحت الغطاء" + }, + "l10n_app_changelog_a_cleaner_home_refreshed_design_a_8c91c06b": { + "en": "A cleaner home - refreshed design, a new inbox, your photo", + "ar": "شاشة رئيسية أنظف — تصميم محدّث، وصندوق وارد جديد، وصورتك" + }, + "l10n_app_changelog_a_fresh_look_new_gold_on_6f379935": { + "en": "A fresh look - new gold-on-navy icon", + "ar": "مظهر جديد — أيقونة ذهبية على أزرق داكن" + }, + "l10n_app_changelog_a_quick_fix_9cd2b7de": { + "en": "A quick fix", + "ar": "إصلاح سريع" + }, + "l10n_app_changelog_a_round_of_fixes_reconnect_exports_7700d972": { + "en": "A round of fixes - reconnect, exports & Health setup", + "ar": "جولة إصلاحات — إعادة الاتصال والتصدير وإعداد Health" + }, + "l10n_app_changelog_a_round_of_fixes_steps_insights_8f8e6010": { + "en": "A round of fixes - steps, Insights & Health setup", + "ar": "جولة إصلاحات — الخطوات والرؤى وإعداد Health" + }, + "l10n_app_changelog_a_round_of_look_and_feel_196f6ef3": { + "en": "A round of look-and-feel polish", + "ar": "جولة تحسينات للمظهر والإحساس" + }, + "l10n_app_changelog_a_small_honest_ask_32f2ec41": { + "en": "A small, honest ask", + "ar": "طلب صغير وصادق" + }, + "l10n_app_changelog_a_smart_wake_alarm_live_workout_0ef814cc": { + "en": "A smart wake alarm, live workout mode, an editable Today, and lifting imports", + "ar": "منبّه استيقاظ ذكي، ووضع تمرين مباشر، و«اليوم» قابل للتحرير، واستيراد تمارين القوة" + }, + "l10n_app_changelog_a_smoother_dashboard_on_big_histories_3e3a8dea": { + "en": "A smoother dashboard on big histories, and a clearer Smart Alarm", + "ar": "لوحة أكثر سلاسة مع السجلات الكبيرة، ومنبّه ذكي أوضح" + }, + "l10n_app_changelog_a_sync_chip_on_today_clearer_37682fc9": { + "en": "A sync chip on Today, clearer strap-clock warnings, and complete German", + "ar": "مؤشر مزامنة في «اليوم»، وتحذيرات أوضح لساعة السوار، وألمانية كاملة" + }, + "l10n_app_changelog_a_whole_new_look_titanium_gold_b40e0108": { + "en": "A whole new look - \"Titanium & Gold\"", + "ar": "مظهر جديد كليًا — «التيتانيوم والذهب»" + }, + "l10n_app_changelog_a_whoop_5_that_stays_connected_7b490eee": { + "en": "A WHOOP 5 that stays connected, your body clock on the Sleep screen, and a Journal that knows No from nothing", + "ar": "‏WHOOP 5 يبقى متصلًا، وساعتك البيولوجية في شاشة النوم، ويوميات تميّز «لا» عن الفراغ" + }, + "l10n_app_changelog_a_whoop_style_today_chart_clearer_9bdd13ff": { + "en": "A WHOOP-style Today chart, clearer sleep times - and a big iPhone update", + "ar": "رسم «اليوم» بأسلوب WHOOP، وأوقات نوم أوضح — وتحديث كبير لـ iPhone" + }, + "l10n_app_changelog_accuracy_reliability_accessibility_a_big_community_43c3787d": { + "en": "Accuracy, reliability & accessibility - a big community-fixes wave", + "ar": "الدقة والموثوقية وإمكانية الوصول — موجة كبيرة من إصلاحات المجتمع" + }, + "l10n_app_changelog_an_effort_scale_you_choose_a_a7c2478d": { + "en": "An Effort scale you choose, a ring that gets to sleep, and far fewer wasted re-scores", + "ar": "مقياس جهد تختاره، وخاتم ينام، وإعادة احتساب مهدرة أقل بكثير" + }, + "l10n_app_changelog_android_bottom_tab_bar_99b41cad": { + "en": "Android: bottom tab bar", + "ar": "‏Android: شريط تبويبات سفلي" + }, + "l10n_app_changelog_android_faster_sync_skin_temp_sync_4f774d79": { + "en": "Android: faster sync, skin temp, sync status, alarm groundwork", + "ar": "‏Android: مزامنة أسرع، وحرارة الجلد، وحالة المزامنة، وأساس المنبّه" + }, + "l10n_app_changelog_android_gps_route_distance_fix_817a80d6": { + "en": "Android GPS route distance fix", + "ar": "إصلاح مسافة مسار GPS على Android" + }, + "l10n_app_changelog_android_home_screen_widget_98b0782a": { + "en": "Android home-screen widget", + "ar": "أداة الشاشة الرئيسية على Android" + }, + "l10n_app_changelog_android_notification_recovery_fix_widget_armour_b84839c6": { + "en": "Android: notification recovery fix + widget armour", + "ar": "‏Android: إصلاح استرداد الإشعارات + تحصين الأداة" + }, + "l10n_app_changelog_android_reconnect_guide_a_startup_crash_9900dd7a": { + "en": "Android reconnect guide + a startup-crash fix", + "ar": "دليل إعادة الاتصال على Android + إصلاح تعطل عند الإقلاع" + }, + "l10n_app_changelog_android_reliable_reconnect_after_a_dropout_c96cb4c0": { + "en": "Android: reliable reconnect after a dropout", + "ar": "‏Android: إعادة اتصال موثوقة بعد الانقطاع" + }, + "l10n_app_changelog_android_share_back_to_health_connect_4d87898e": { + "en": "Android: share back to Health Connect", + "ar": "‏Android: المشاركة العكسية إلى Health Connect" + }, + "l10n_app_changelog_android_the_widget_now_actually_updates_dc770011": { + "en": "Android: the widget now actually updates", + "ar": "‏Android: الأداة تتحدّث فعلًا الآن" + }, + "l10n_app_changelog_android_today_clearer_empty_states_39ad5d2c": { + "en": "Android Today: clearer empty states", + "ar": "«اليوم» على Android: حالات فارغة أوضح" + }, + "l10n_app_changelog_android_whoop_4_on_newer_firmware_383314db": { + "en": "Android: WHOOP 4 on newer firmware now records data", + "ar": "‏Android: WHOOP 4 على البرامج الثابتة الأحدث يسجّل البيانات الآن" + }, + "l10n_app_changelog_apple_health_export_sleep_nights_recovered_1fc04ee2": { + "en": "Apple Health export, sleep nights recovered, and imported-ride Effort", + "ar": "التصدير إلى Apple Health، واسترجاع ليالي النوم، وجهد الرحلات المستوردة" + }, + "l10n_app_changelog_auto_sync_health_connect_android_698f0129": { + "en": "Auto-sync Health Connect (Android)", + "ar": "مزامنة تلقائية لـ Health Connect (Android)" + }, + "l10n_app_changelog_background_gps_sleep_time_editing_log_7baa5d7a": { + "en": "Background GPS, sleep-time editing, log-ahead, and a sharper Rest tile", + "ar": "‏GPS في الخلفية، وتحرير أوقات النوم، والتسجيل المسبق، وبطاقة راحة أوضح" + }, + "l10n_app_changelog_backup_controls_and_a_lighter_app_a08f0fe4": { + "en": "Backup controls, and a lighter app", + "ar": "عناصر تحكم بالنسخ الاحتياطي، وتطبيق أخف" + }, + "l10n_app_changelog_backup_restore_fixed_plus_appearance_controls_d0b9c0fe": { + "en": "Backup restore fixed, plus appearance controls", + "ar": "إصلاح استعادة النسخة الاحتياطية، مع عناصر تحكم بالمظهر" + }, + "l10n_app_changelog_backup_restore_the_wrong_day_fix_ecbfd90d": { + "en": "Backup & Restore, the wrong-day fix, and a smarter Test Centre", + "ar": "النسخ الاحتياطي والاستعادة، وإصلاح اليوم الخاطئ، ومركز اختبار أذكى" + }, + "l10n_app_changelog_battery_refresh_on_whoop_5_0_0304ac74": { + "en": "Battery refresh on WHOOP 5.0/MG (Mac + Android)", + "ar": "تحديث البطارية على WHOOP 5.0/MG (Mac + Android)" + }, + "l10n_app_changelog_battery_responsiveness_smarter_sync_lighter_notification_e6949f3c": { + "en": "Battery + responsiveness: smarter sync, lighter notification", + "ar": "البطارية والاستجابة: مزامنة أذكى، وإشعار أخف" + }, + "l10n_app_changelog_battery_saver_quiets_the_gauges_translated_bdbe8650": { + "en": "Battery saver quiets the gauges, translated Android notifications, and instant chart loads", + "ar": "موفّر البطارية يهدّئ المؤشرات، وإشعارات Android مترجمة، وتحميل فوري للرسوم" + }, + "l10n_app_changelog_better_diagnostics_for_newer_strap_firmware_a7ef4187": { + "en": "Better diagnostics for newer strap firmware - so we can decode it", + "ar": "تشخيصات أفضل للبرامج الثابتة الأحدث للسوار — حتى نتمكن من فكّ ترميزها" + }, + "l10n_app_changelog_better_strap_log_diagnostics_122f820c": { + "en": "Better strap-log diagnostics", + "ar": "تشخيصات أفضل لسجل السوار" + }, + "l10n_app_changelog_big_fix_wave_clock_reconnect_local_a2959a50": { + "en": "Big fix wave - clock, reconnect, local LLM, Explore, weight and more", + "ar": "موجة إصلاحات كبيرة — الساعة، وإعادة الاتصال، ونموذج لغوي محلي، والاستكشاف، والوزن وغيرها" + }, + "l10n_app_changelog_bluetooth_stream_apple_health_sync_fixes_78e16558": { + "en": "Bluetooth stream + Apple Health sync fixes", + "ar": "إصلاحات بث Bluetooth ومزامنة Apple Health" + }, + "l10n_app_changelog_board_sweep_battery_days_left_browse_8b78e0b6": { + "en": "Board Sweep: battery days-left, browse past weeks, breathing cues, and a pile of fixes", + "ar": "تنظيف اللوحة: أيام البطارية المتبقية، وتصفح الأسابيع الماضية، وتنبيهات التنفس، وكومة إصلاحات" + }, + "l10n_app_changelog_broadcast_your_heart_rate_to_garmin_338605c5": { + "en": "Broadcast your heart rate to Garmin, Zwift and gym kit", + "ar": "بثّ معدل نبضك إلى Garmin وZwift وأجهزة النادي" + }, + "l10n_app_changelog_browse_past_nights_smarter_coach_workout_9fc9bfd2": { + "en": "Browse past nights, smarter Coach, workout times, battery & more", + "ar": "تصفح الليالي الماضية، ومدرب أذكى، وأوقات التمارين، والبطارية وغيرها" + }, + "l10n_app_changelog_browse_the_last_few_days_interactive_a13e7b2a": { + "en": "Browse the last few days, interactive charts, and a Vital Signs screen (Android)", + "ar": "تصفح الأيام القليلة الماضية، ورسوم تفاعلية، وشاشة العلامات الحيوية (Android)" + }, + "l10n_app_changelog_bug_fix_sweep_steps_sleep_export_50c60c51": { + "en": "Bug-fix sweep: steps, sleep export, and a battery-saving reconnect fix", + "ar": "موجة إصلاحات: الخطوات، وتصدير النوم، وإصلاح إعادة اتصال موفّر للبطارية" + }, + "l10n_app_changelog_bug_fixes_effort_the_widget_s_6b807b50": { + "en": "Bug fixes: Effort, the widget\\'s day, and Oura reconnect", + "ar": "إصلاحات: الجهد، ويوم الأداة، وإعادة اتصال Oura" + }, + "l10n_app_changelog_buzz_whoop_4_in_the_smart_4d17676a": { + "en": "Buzz WHOOP 4 in the Smart Alarm", + "ar": "اهتزاز WHOOP 4 في المنبّه الذكي" + }, + "l10n_app_changelog_charge_effort_rest_noop_s_own_8c323656": { + "en": "Charge, Effort & Rest - NOOP\\'s own scores, out of 100", + "ar": "الشحن والجهد والراحة — درجات NOOP الخاصة من 100" + }, + "l10n_app_changelog_charge_shows_calibrating_instead_of_no_51a000e9": { + "en": "Charge shows \"Calibrating\" instead of \"No data\" for new straps", + "ar": "الشحن يعرض «جارٍ المعايرة» بدلًا من «لا بيانات» للأساور الجديدة" + }, + "l10n_app_changelog_check_for_updates_736b9062": { + "en": "Check for updates", + "ar": "التحقق من التحديثات" + }, + "l10n_app_changelog_choose_a_12_hour_clock_sleep_8a19db5c": { + "en": "Choose a 12-hour clock, sleep from straps that bank no motion, and a strap log that stops guessing", + "ar": "اختيار نظام 12 ساعة، ونوم من أساور لا تخزّن حركة، وسجل سوار يتوقف عن التخمين" + }, + "l10n_app_changelog_classic_chart_colours_a_throwback_toggle_e8150db5": { + "en": "Classic chart colours - a throwback toggle", + "ar": "ألوان الرسوم الكلاسيكية — مفتاح للعودة إلى الماضي" + }, + "l10n_app_changelog_cleaner_live_status_better_sync_diagnostics_00ad8d26": { + "en": "Cleaner Live status + better sync diagnostics", + "ar": "حالة مباشرة أنظف + تشخيصات مزامنة أفضل" + }, + "l10n_app_changelog_cleaner_score_rings_a_few_fixes_7094ae7b": { + "en": "Cleaner score rings + a few fixes", + "ar": "حلقات درجات أنظف + بعض الإصلاحات" + }, + "l10n_app_changelog_clearer_answers_when_your_strap_isn_154c8923": { + "en": "Clearer answers when your strap isn\\'t banking history", + "ar": "إجابات أوضح حين لا يخزّن سوارك السجل" + }, + "l10n_app_changelog_clearer_labels_a_journal_fix_and_96b91b2a": { + "en": "Clearer labels, a journal fix, and steadier sleeping heart rate", + "ar": "تسميات أوضح، وإصلاح لليوميات، ومعدل نبض أثناء النوم أكثر ثباتًا" + }, + "l10n_app_changelog_clearer_pairing_guidance_for_whoop_5_91a7736b": { + "en": "Clearer pairing guidance for WHOOP 5.0/MG", + "ar": "إرشادات اقتران أوضح لـ WHOOP 5.0/MG" + }, + "l10n_app_changelog_clearer_sync_status_6431bf19": { + "en": "Clearer sync status", + "ar": "حالة مزامنة أوضح" + }, + "l10n_app_changelog_clearer_sync_status_a_responsive_compare_ee8925d0": { + "en": "Clearer sync status + a responsive Compare screen", + "ar": "حالة مزامنة أوضح + شاشة مقارنة متجاوبة" + }, + "l10n_app_changelog_connect_a_heart_rate_strap_early_f12c6ba9": { + "en": "Connect a heart-rate strap (early access)", + "ar": "توصيل سوار لقياس النبض (وصول مبكر)" + }, + "l10n_app_changelog_connection_sleep_fixes_a_focused_tune_69b01439": { + "en": "Connection & sleep fixes - a focused tune-up", + "ar": "إصلاحات الاتصال والنوم — ضبط مركّز" + }, + "l10n_app_changelog_continuous_hrv_capture_sharper_overnight_hrv_9a9ec94b": { + "en": "Continuous HRV capture - sharper overnight HRV, recovery and sleep", + "ar": "التقاط مستمر لتقلب النبض — تقلب نبض وتعافٍ ونوم أدق ليلًا" + }, + "l10n_app_changelog_continuous_workouts_no_longer_split_plus_4db70213": { + "en": "Continuous workouts no longer split, plus delete a sleep session", + "ar": "التمارين المتصلة لم تعد تنقسم، مع إمكانية حذف جلسة نوم" + }, + "l10n_app_changelog_coupled_view_workouts_rebuilt_journal_numbers_b347f97e": { + "en": "Coupled view, workouts rebuilt, journal numbers", + "ar": "العرض المزدوج، وإعادة بناء التمارين، وأرقام اليوميات" + }, + "l10n_app_changelog_cross_platform_parity_android_now_scores_86654a6c": { + "en": "Cross-platform parity - Android now scores identically to macOS & iOS", + "ar": "تكافؤ عبر المنصات — Android يحتسب الآن بشكل مطابق لـ macOS وiOS" + }, + "l10n_app_changelog_date_fixes_ui_polish_clearer_diagnostics_61058e74": { + "en": "Date fixes, UI polish & clearer diagnostics", + "ar": "إصلاحات التواريخ، وتحسينات الواجهة، وتشخيصات أوضح" + }, + "l10n_app_changelog_date_hygiene_fix_for_straps_with_dc559c6f": { + "en": "Date-hygiene fix for straps with a bad clock", + "ar": "إصلاح صحة التواريخ للأساور ذات الساعة الخاطئة" + }, + "l10n_app_changelog_deep_history_backlog_drains_without_manual_bb716532": { + "en": "Deep history backlog drains without manual taps", + "ar": "تراكم السجل العميق يُفرَّغ دون نقرات يدوية" + }, + "l10n_app_changelog_deep_sleep_no_longer_reads_0_80ce3558": { + "en": "Deep sleep no longer reads 0 minutes, and a smarter AI Coach", + "ar": "النوم العميق لم يعد يظهر 0 دقيقة، ومدرب ذكي أفضل" + }, + "l10n_app_changelog_deep_sleep_that_happens_later_in_702efdf1": { + "en": "Deep sleep that happens later in the night no longer reads 0 minutes", + "ar": "النوم العميق في وقت متأخر من الليل لم يعد يظهر 0 دقيقة" + }, + "l10n_app_changelog_deep_timeline_you_can_scroll_through_7c04c040": { + "en": "Deep Timeline you can scroll through days, faster manual workouts, and a storage clean-up", + "ar": "جدول زمني عميق يمكنك تمريره عبر الأيام، وتمارين يدوية أسرع، وتنظيف للتخزين" + }, + "l10n_app_changelog_delete_a_sleep_swipe_to_mark_7083b34d": { + "en": "Delete a sleep, swipe to mark read", + "ar": "حذف نوم، والسحب للتعليم كمقروء" + }, + "l10n_app_changelog_design_polish_cross_platform_parity_caa8e2c4": { + "en": "Design polish & cross-platform parity", + "ar": "تحسينات التصميم والتكافؤ عبر المنصات" + }, + "l10n_app_changelog_dynamic_island_toggle_now_actually_turns_df387ee6": { + "en": "Dynamic Island toggle now actually turns it off", + "ar": "مفتاح Dynamic Island يوقفه فعلًا الآن" + }, + "l10n_app_changelog_editable_naps_a_richer_trends_report_471c1a69": { + "en": "Editable naps, a richer Trends report, and better debug export", + "ar": "قيلولات قابلة للتحرير، وتقرير اتجاهات أغنى، وتصدير تصحيح أفضل" + }, + "l10n_app_changelog_effort_explains_a_calm_day_zero_08bd9936": { + "en": "Effort explains a calm-day zero - and scores on the 5.0/MG", + "ar": "الجهد يفسّر صفر اليوم الهادئ — ويحتسب على 5.0/MG" + }, + "l10n_app_changelog_effort_scale_fix_for_imported_data_aad0344d": { + "en": "Effort scale fix for imported data", + "ar": "إصلاح مقياس الجهد للبيانات المستوردة" + }, + "l10n_app_changelog_estimated_steps_for_your_whoop_4_e961cade": { + "en": "Estimated steps for your WHOOP 4.0", + "ar": "خطوات تقديرية لـ WHOOP 4.0" + }, + "l10n_app_changelog_everything_a_whole_new_look_hydration_0f648fc3": { + "en": "Everything: a whole new look, hydration, automatic workout detection, and smarter sleep", + "ar": "كل شيء: مظهر جديد كليًا، والترطيب، والكشف التلقائي عن التمارين، ونوم أذكى" + }, + "l10n_app_changelog_everything_stays_on_your_device_575125e9": { + "en": "Everything stays on your device", + "ar": "كل شيء يبقى على جهازك" + }, + "l10n_app_changelog_experimental_unlocking_whoop_5_0_mg_d665f836": { + "en": "Experimental: unlocking WHOOP 5.0/MG deep data", + "ar": "تجريبي: فتح البيانات العميقة لـ WHOOP 5.0/MG" + }, + "l10n_app_changelog_export_your_raw_sensor_data_csv_59cfa009": { + "en": "Export your raw sensor data (CSV)", + "ar": "تصدير بيانات المستشعرات الخام (CSV)" + }, + "l10n_app_changelog_faster_and_fewer_sharp_edges_6bbd66c7": { + "en": "Faster, and fewer sharp edges", + "ar": "أسرع، وحواف حادة أقل" + }, + "l10n_app_changelog_faster_smoother_more_languages_and_a_6a8c8837": { + "en": "Faster, smoother, more languages, and a big pile of fixes", + "ar": "أسرع وأكثر سلاسة، ولغات أكثر، وكومة كبيرة من الإصلاحات" + }, + "l10n_app_changelog_fewer_false_daytime_sleeps_an_android_6bf9029e": { + "en": "Fewer false daytime sleeps + an Android sync button", + "ar": "نوم نهاري كاذب أقل + زر مزامنة على Android" + }, + "l10n_app_changelog_find_your_strap_log_in_settings_99105f3a": { + "en": "Find your strap log in Settings (macOS)", + "ar": "اعثر على سجل سوارك في الإعدادات (macOS)" + }, + "l10n_app_changelog_first_release_d5545946": { + "en": "First release", + "ar": "الإصدار الأول" + }, + "l10n_app_changelog_first_run_terms_acknowledgment_an_explore_081534f5": { + "en": "First-run terms acknowledgment + an Explore chart fix", + "ar": "إقرار بالشروط عند أول تشغيل + إصلاح رسم الاستكشاف" + }, + "l10n_app_changelog_fix_a_night_with_a_brief_538bfa1d": { + "en": "Fix: a night with a brief wake-up showed as separate naps", + "ar": "إصلاح: ليلة بها استيقاظ قصير كانت تظهر كقيلولات منفصلة" + }, + "l10n_app_changelog_fix_app_crashing_won_t_open_d4b0acce": { + "en": "Fix: app crashing / won\\'t open when Bluetooth is on", + "ar": "إصلاح: تعطل التطبيق / عدم فتحه عند تشغيل Bluetooth" + }, + "l10n_app_changelog_fix_bonded_but_no_live_data_fbab40a4": { + "en": "Fix: bonded but no live data (Android)", + "ar": "إصلاح: مقترن لكن دون بيانات مباشرة (Android)" + }, + "l10n_app_changelog_fix_connecting_a_polar_h10_or_bf013d57": { + "en": "Fix: connecting a Polar H10 or other heart-rate strap", + "ar": "إصلاح: توصيل Polar H10 أو أساور نبض أخرى" + }, + "l10n_app_changelog_fix_imported_phone_steps_were_being_7c152d2b": { + "en": "Fix: imported phone steps were being double-counted", + "ar": "إصلاح: خطوات الهاتف المستوردة كانت تُحتسب مرتين" + }, + "l10n_app_changelog_fix_the_android_freeze_after_a_bf7f8d81": { + "en": "Fix the Android freeze after a few nights of data", + "ar": "إصلاح تجمّد Android بعد بضع ليالٍ من البيانات" + }, + "l10n_app_changelog_fix_your_day_now_follows_your_e772176e": { + "en": "Fix: your day now follows your timezone, not UTC", + "ar": "إصلاح: يومك يتبع الآن منطقتك الزمنية لا UTC" + }, + "l10n_app_changelog_fixed_imported_data_and_strap_sync_18350836": { + "en": "Fixed: imported data and strap sync getting stuck on iOS", + "ar": "تم الإصلاح: تعليق البيانات المستوردة ومزامنة السوار على iOS" + }, + "l10n_app_changelog_fixed_iphone_import_and_a_stuck_38876ee9": { + "en": "Fixed: iPhone import, and a stuck store now self-heals", + "ar": "تم الإصلاح: استيراد iPhone، والمخزن المتوقف يعالج نفسه الآن" + }, + "l10n_app_changelog_fixes_a_false_pairing_refused_warning_7023464e": { + "en": "Fixes a false \"pairing refused\" warning (Mac)", + "ar": "يصلح تحذير «رُفض الاقتران» الخاطئ (Mac)" + }, + "l10n_app_changelog_fixes_the_experimental_sleep_toggle_now_fc281b7d": { + "en": "Fixes: the experimental sleep toggle now works, steps calibration, manual workouts on WHOOP 5/MG, and a sane HRV reading", + "ar": "إصلاحات: مفتاح النوم التجريبي يعمل الآن، ومعايرة الخطوات، والتمارين اليدوية على WHOOP 5/MG، وقراءة معقولة لتقلب النبض" + }, + "l10n_app_changelog_fixes_the_insights_tab_crash_plus_042d1702": { + "en": "Fixes the Insights-tab crash, plus more accurate HRV", + "ar": "يصلح تعطل تبويب الرؤى، مع تقلب نبض أدق" + }, + "l10n_app_changelog_french_whoop_exports_now_import_db598ea4": { + "en": "French WHOOP exports now import", + "ar": "ملفات تصدير WHOOP الفرنسية تُستورد الآن" + }, + "l10n_app_changelog_german_french_spanish_pull_to_sync_1109bda2": { + "en": "German, French & Spanish, pull-to-sync on Today, and a wave of polish", + "ar": "الألمانية والفرنسية والإسبانية، والسحب للمزامنة في «اليوم»، وموجة تحسينات" + }, + "l10n_app_changelog_gps_tracked_workouts_android_1cf07bd1": { + "en": "GPS-tracked workouts (Android)", + "ar": "تمارين متتبَّعة بـ GPS (Android)" + }, + "l10n_app_changelog_gps_workout_crash_fix_android_698ee690": { + "en": "GPS workout crash fix (Android)", + "ar": "إصلاح تعطل تمرين GPS (Android)" + }, + "l10n_app_changelog_hand_correct_your_sleep_times_smaller_6154b854": { + "en": "Hand-correct your sleep times + smaller backups", + "ar": "تصحيح أوقات نومك يدويًا + نسخ احتياطية أصغر" + }, + "l10n_app_changelog_health_connect_correct_labels_workout_types_fd2fcc34": { + "en": "Health Connect: correct labels + workout types (Android)", + "ar": "‏Health Connect: تسميات صحيحة + أنواع التمارين (Android)" + }, + "l10n_app_changelog_health_connect_shows_as_health_connect_39b3cbcb": { + "en": "Health Connect shows as Health Connect", + "ar": "‏Health Connect يظهر باسم Health Connect" + }, + "l10n_app_changelog_history_dates_fixed_for_revived_straps_0ec4e53a": { + "en": "History dates fixed for revived straps, gestures during sync, clearer pairing", + "ar": "إصلاح تواريخ السجل للأساور المستعادة، والإيماءات أثناء المزامنة، واقتران أوضح" + }, + "l10n_app_changelog_honest_labelling_for_whoop_5_mg_8c650225": { + "en": "Honest labelling for WHOOP 5/MG deep-data diagnostics", + "ar": "تسميات صادقة لتشخيصات البيانات العميقة لـ WHOOP 5/MG" + }, + "l10n_app_changelog_hotfix_making_a_heart_rate_strap_09f87c0a": { + "en": "Hotfix - making a heart-rate strap active no longer crashes", + "ar": "إصلاح عاجل — تعيين سوار نبض نشطًا لم يعد يسبب تعطلًا" + }, + "l10n_app_changelog_hr_from_the_optical_waveform_an_b8874b42": { + "en": "HR from the optical waveform, an early-morning day rollover, and clearer terms", + "ar": "معدل النبض من الموجة الضوئية، وانتقال اليوم في الصباح الباكر، وشروط أوضح" + }, + "l10n_app_changelog_hrv_accuracy_fix_oura_sleep_and_4ed31e39": { + "en": "HRV accuracy fix, Oura sleep and motion, richer 5/MG decoding, and a stoppable sync", + "ar": "إصلاح دقة تقلب النبض، ونوم وحركة Oura، وفكّ ترميز أغنى لـ 5/MG، ومزامنة قابلة للإيقاف" + }, + "l10n_app_changelog_hrv_that_reads_true_and_a_2b09fa43": { + "en": "HRV that reads true, and a tidier workout list", + "ar": "تقلب نبض يقرأ الحقيقة، وقائمة تمارين أكثر ترتيبًا" + }, + "l10n_app_changelog_import_fixes_both_sources_all_data_92cfed50": { + "en": "Import fixes - both sources, all data types", + "ar": "إصلاحات الاستيراد — كلا المصدرين، وكل أنواع البيانات" + }, + "l10n_app_changelog_import_polish_mac_whoop_5_optical_5067d90c": { + "en": "Import polish (Mac) + WHOOP 5 optical decode", + "ar": "تحسينات الاستيراد (Mac) + فكّ الترميز الضوئي لـ WHOOP 5" + }, + "l10n_app_changelog_independent_and_experimental_f9b65317": { + "en": "Independent, and experimental", + "ar": "مستقل وتجريبي" + }, + "l10n_app_changelog_ios_is_now_a_direct_download_fab6ed69": { + "en": "iOS is now a direct download - no Mac or Xcode needed", + "ar": "‏iOS صار تنزيلًا مباشرًا — دون حاجة إلى Mac أو Xcode" + }, + "l10n_app_changelog_iphone_button_label_polish_64244980": { + "en": "iPhone button-label polish", + "ar": "تحسين تسميات أزرار iPhone" + }, + "l10n_app_changelog_iphone_hotfix_sideloading_works_again_7b90c265": { + "en": "iPhone hotfix: sideloading works again", + "ar": "إصلاح عاجل لـ iPhone: التثبيت الجانبي يعمل مجددًا" + }, + "l10n_app_changelog_iphone_import_handle_icloud_and_large_2d60298a": { + "en": "iPhone import: handle iCloud and large export files", + "ar": "استيراد iPhone: التعامل مع iCloud وملفات التصدير الكبيرة" + }, + "l10n_app_changelog_iphone_polish_accessibility_99bba695": { + "en": "iPhone polish + accessibility", + "ar": "تحسينات iPhone + إمكانية الوصول" + }, + "l10n_app_changelog_iphone_polish_what_s_new_fits_1dad9b15": { + "en": "iPhone polish: What\\'s New fits, Today cards align", + "ar": "تحسينات iPhone: «ما الجديد» يتّسع، وبطاقات «اليوم» تصطف" + }, + "l10n_app_changelog_iphone_smoother_scrolling_753e0b0d": { + "en": "iPhone: smoother scrolling", + "ar": "‏iPhone: تمرير أكثر سلاسة" + }, + "l10n_app_changelog_iphone_workouts_table_fits_the_screen_156a691c": { + "en": "iPhone Workouts table fits the screen", + "ar": "جدول التمارين على iPhone يتّسع للشاشة" + }, + "l10n_app_changelog_journal_logging_an_imperial_metric_units_a507a5d2": { + "en": "Journal logging + an Imperial/Metric units toggle", + "ar": "تسجيل اليوميات + مفتاح الوحدات الإمبراطورية/المترية" + }, + "l10n_app_changelog_large_apple_health_imports_no_longer_830fa786": { + "en": "Large Apple Health imports no longer crash (iPhone/Mac)", + "ar": "عمليات استيراد Apple Health الكبيرة لم تعد تسبب تعطلًا (iPhone/Mac)" + }, + "l10n_app_changelog_last_workouts_tile_fix_301180af": { + "en": "Last Workouts tile fix", + "ar": "إصلاح بطاقة آخر التمارين" + }, + "l10n_app_changelog_light_theme_noop_in_warm_paper_d5b9f463": { + "en": "Light theme - NOOP in warm paper & gold", + "ar": "السمة الفاتحة — NOOP بورق دافئ وذهب" + }, + "l10n_app_changelog_light_theme_polish_5add98b2": { + "en": "Light theme polish", + "ar": "تحسينات السمة الفاتحة" + }, + "l10n_app_changelog_light_theme_tuning_2bbad1f7": { + "en": "Light theme tuning", + "ar": "ضبط السمة الفاتحة" + }, + "l10n_app_changelog_live_heart_rate_lands_on_today_5cac8768": { + "en": "Live heart rate lands on today\\'s chart even when the strap\\'s clock is off (Android)", + "ar": "النبض المباشر يظهر في رسم اليوم حتى لو كانت ساعة السوار خاطئة (Android)" + }, + "l10n_app_changelog_live_heart_rate_that_doesn_t_b6b4ca9a": { + "en": "Live heart rate that doesn\\'t freeze", + "ar": "نبض مباشر لا يتجمّد" + }, + "l10n_app_changelog_local_oura_ring_support_use_your_e928e3c6": { + "en": "Local Oura ring support: use your Oura ring with no Oura app (beta)", + "ar": "دعم محلي لخاتم Oura: استخدم خاتمك دون تطبيق Oura (تجريبي)" + }, + "l10n_app_changelog_mac_recovery_builds_from_your_strap_883d49ee": { + "en": "Mac: recovery builds from your strap alone", + "ar": "‏Mac: التعافي يُحتسب من سوارك وحده" + }, + "l10n_app_changelog_mac_strap_computed_nights_show_in_b0a3ac91": { + "en": "Mac: strap-computed nights show in Sleep", + "ar": "‏Mac: الليالي المحتسبة من السوار تظهر في «النوم»" + }, + "l10n_app_changelog_make_noop_yours_theme_colours_and_6bdef2a7": { + "en": "Make NOOP yours — theme colours and custom backgrounds, forty more sports with GPS routes, and a calorie heatmap", + "ar": "اجعل NOOP لك — ألوان السمة والخلفيات المخصصة، وأربعون رياضة إضافية بمسارات GPS، وخريطة حرارية للسعرات" + }, + "l10n_app_changelog_manage_several_whoop_straps_and_see_8d9b6a84": { + "en": "Manage several WHOOP straps - and see what each band does", + "ar": "إدارة عدة أساور WHOOP — ومعرفة ما يفعله كل سوار" + }, + "l10n_app_changelog_manual_workouts_edit_dismiss_auto_detected_95ed45b6": { + "en": "Manual workouts, edit/dismiss auto-detected ones, and CSV export", + "ar": "تمارين يدوية، وتحرير/تجاهل المكتشفة تلقائيًا، وتصدير CSV" + }, + "l10n_app_changelog_manual_workouts_on_whoop_5_0_72908a88": { + "en": "Manual workouts on WHOOP 5.0/MG get their calories and strain back", + "ar": "التمارين اليدوية على WHOOP 5.0/MG تستعيد سعراتها وإجهادها" + }, + "l10n_app_changelog_mi_band_import_a_big_whoop_eb5382db": { + "en": "Mi Band import + a big WHOOP 4.0 sleep fix", + "ar": "استيراد Mi Band + إصلاح كبير لنوم WHOOP 4.0" + }, + "l10n_app_changelog_mind_a_daily_mood_check_in_982fdd16": { + "en": "Mind - a daily mood check-in - and nutrition import", + "ar": "«الذهن» — تسجيل يومي للمزاج — واستيراد التغذية" + }, + "l10n_app_changelog_more_quick_relabel_sports_ba93c38b": { + "en": "More quick-relabel sports", + "ar": "رياضات أكثر لإعادة التسمية السريعة" + }, + "l10n_app_changelog_more_realistic_calories_import_chart_fixes_d3d56a9e": { + "en": "More realistic calories + import & chart fixes", + "ar": "سعرات أكثر واقعية + إصلاحات الاستيراد والرسوم" + }, + "l10n_app_changelog_more_reliable_bluetooth_on_newer_android_c7e641dc": { + "en": "More reliable Bluetooth on newer Android phones", + "ar": "‏Bluetooth أكثر موثوقية على هواتف Android الأحدث" + }, + "l10n_app_changelog_more_tab_icons_stop_flickering_colour_3de3de9f": { + "en": "More-tab icons stop flickering colour", + "ar": "أيقونات تبويب «المزيد» توقفت عن وميض اللون" + }, + "l10n_app_changelog_new_a_guide_to_how_your_7afbd7f4": { + "en": "New: a guide to how your Charge, Effort and Rest scores work", + "ar": "جديد: دليل لكيفية عمل درجات الشحن والجهد والراحة" + }, + "l10n_app_changelog_new_first_run_onboarding_mac_android_243bc0ad": { + "en": "New first-run onboarding (Mac + Android)", + "ar": "تهيئة جديدة عند أول تشغيل (Mac + Android)" + }, + "l10n_app_changelog_new_use_an_apple_watch_with_4c2c29a6": { + "en": "New: use an Apple Watch with NOOP", + "ar": "جديد: استخدام Apple Watch مع NOOP" + }, + "l10n_app_changelog_new_week_in_review_a_live_c1457e40": { + "en": "New: Week in review, a live body console, fresher charts and more", + "ar": "جديد: مراجعة الأسبوع، ولوحة جسد مباشرة، ورسوم أحدث وغيرها" + }, + "l10n_app_changelog_no_more_hr_spike_when_you_7dc052ba": { + "en": "No more HR spike when you reopen the app", + "ar": "لا مزيد من قفزة النبض عند إعادة فتح التطبيق" + }, + "l10n_app_changelog_noop_grows_up_it_s_not_823bf2e7": { + "en": "NOOP grows up: it\\'s not just for WHOOP anymore", + "ar": "‏NOOP يكبر: لم يعد لـ WHOOP فقط" + }, + "l10n_app_changelog_noop_has_a_new_home_01b87522": { + "en": "NOOP has a new home", + "ar": "لـ NOOP بيت جديد" + }, + "l10n_app_changelog_noop_now_tells_you_when_your_af259fbb": { + "en": "NOOP now tells you when your strap isn\\'t saving history - and how to fix it", + "ar": "‏NOOP يخبرك الآن حين لا يحفظ سوارك السجل — وكيف تصلح ذلك" + }, + "l10n_app_changelog_on_demand_hrv_a_haptic_clock_50376585": { + "en": "On-demand HRV, a haptic clock, sleep marks & more", + "ar": "تقلب نبض عند الطلب، وساعة اهتزازية، وعلامات نوم وغيرها" + }, + "l10n_app_changelog_oneplus_pairing_fix_f731fcb2": { + "en": "OnePlus pairing fix", + "ar": "إصلاح اقتران OnePlus" + }, + "l10n_app_changelog_open_a_workout_see_what_it_a40fd8a4": { + "en": "Open a workout, see what it costs you, and share your trends", + "ar": "افتح تمرينًا، وانظر ما يكلّفك، وشارك اتجاهاتك" + }, + "l10n_app_changelog_optimal_strain_alerts_faster_history_sync_6488a27a": { + "en": "Optimal-strain alerts, faster history sync, and a wave of accuracy fixes", + "ar": "تنبيهات الإجهاد الأمثل، ومزامنة سجل أسرع، وموجة إصلاحات للدقة" + }, + "l10n_app_changelog_optional_inactivity_nudge_3f4ca05a": { + "en": "Optional inactivity nudge", + "ar": "تنبيه اختياري للخمول" + }, + "l10n_app_changelog_personal_vital_baselines_mac_analytics_parity_744972d1": { + "en": "Personal vital baselines + Mac analytics parity", + "ar": "خطوط أساس شخصية للعلامات الحيوية + تكافؤ تحليلات Mac" + }, + "l10n_app_changelog_personalized_heart_rate_zones_compare_and_8d89ee2a": { + "en": "Personalized heart-rate zones, compare and switch between straps, and more honest HRV, sleep and Oura reads", + "ar": "مناطق نبض مخصصة، ومقارنة الأساور والتبديل بينها، وقراءات أصدق لتقلب النبض والنوم وOura" + }, + "l10n_app_changelog_polish_matched_to_the_iphone_mac_cc34126b": { + "en": "Polish - matched to the iPhone & Mac design", + "ar": "تحسينات — متوائمة مع تصميم iPhone وMac" + }, + "l10n_app_changelog_power_saving_that_protects_your_strap_57a32503": { + "en": "Power saving that protects your strap, a Gemini-powered coach on Android, and richer metric detail", + "ar": "توفير طاقة يحمي سوارك، ومدرب مدعوم بـ Gemini على Android، وتفاصيل مقاييس أغنى" + }, + "l10n_app_changelog_raw_spo_honest_units_and_a_55e83a59": { + "en": "Raw SpO₂, honest units, and a lighter app", + "ar": "‏SpO₂ الخام، ووحدات صادقة، وتطبيق أخف" + }, + "l10n_app_changelog_re_scan_actually_scans_on_android_22198c8c": { + "en": "Re-scan actually scans on Android", + "ar": "إعادة البحث تبحث فعلًا على Android" + }, + "l10n_app_changelog_readiness_and_the_start_of_whoop_167f38c5": { + "en": "Readiness, and the start of WHOOP 5/MG", + "ar": "الجاهزية، وبداية دعم WHOOP 5/MG" + }, + "l10n_app_changelog_readiness_shows_its_evidence_and_a_90cf0076": { + "en": "Readiness shows its evidence, and a Health Connect distance fix", + "ar": "الجاهزية تعرض أدلتها، وإصلاح مسافة Health Connect" + }, + "l10n_app_changelog_reading_more_from_your_whoop_5_1412164a": { + "en": "Reading more from your WHOOP 5.0 (Mac)", + "ar": "قراءة المزيد من WHOOP 5.0 (Mac)" + }, + "l10n_app_changelog_reconnect_help_for_whoop_5_0_acc779b6": { + "en": "Reconnect help for WHOOP 5.0 / MG after a firmware update", + "ar": "مساعدة إعادة الاتصال لـ WHOOP 5.0 / MG بعد تحديث البرنامج الثابت" + }, + "l10n_app_changelog_reconnects_automatically_after_an_update_android_b4965179": { + "en": "Reconnects automatically after an update (Android)", + "ar": "إعادة اتصال تلقائية بعد التحديث (Android)" + }, + "l10n_app_changelog_recovery_builds_from_your_strap_alone_d83dbd67": { + "en": "Recovery builds from your strap alone (Android)", + "ar": "التعافي يُحتسب من سوارك وحده (Android)" + }, + "l10n_app_changelog_respiratory_rate_skin_temp_in_the_bfe3f4f9": { + "en": "Respiratory rate & skin temp in the Trends report", + "ar": "معدل التنفس وحرارة الجلد في تقرير الاتجاهات" + }, + "l10n_app_changelog_restart_your_strap_lighter_on_battery_2ccbef88": { + "en": "Restart your strap, lighter on battery, and Health Connect on Android 13", + "ar": "إعادة تشغيل سوارك، واستهلاك بطارية أقل، وHealth Connect على Android 13" + }, + "l10n_app_changelog_robust_apple_health_import_marginal_radio_27d78c57": { + "en": "Robust Apple Health import, marginal-radio HR mode, live HR graph", + "ar": "استيراد قوي من Apple Health، ووضع نبض للإشارة الضعيفة، ورسم نبض مباشر" + }, + "l10n_app_changelog_round_rings_73de603c": { + "en": "Round rings", + "ar": "حلقات دائرية" + }, + "l10n_app_changelog_run_the_ai_coach_on_your_8ac2b395": { + "en": "Run the AI Coach on your own model - including fully local", + "ar": "شغّل المدرب الذكي على نموذجك الخاص — بما في ذلك محلي بالكامل" + }, + "l10n_app_changelog_scores_live_from_the_strap_9d5d8ae2": { + "en": "Scores live from the strap", + "ar": "درجات مباشرة من السوار" + }, + "l10n_app_changelog_security_reliability_hardening_b3c9a976": { + "en": "Security & reliability hardening", + "ar": "تعزيز الأمان والموثوقية" + }, + "l10n_app_changelog_see_everything_the_deep_timeline_a_dbfe539a": { + "en": "See Everything: the Deep Timeline, a sleep movement graph, and a big board-clear", + "ar": "شاهد كل شيء: الجدول الزمني العميق، ورسم حركة النوم، وتنظيف كبير للوحة" + }, + "l10n_app_changelog_share_strap_logs_and_a_worn_b5aae860": { + "en": "Share strap logs, and a worn-status fix", + "ar": "مشاركة سجلات السوار، وإصلاح حالة الارتداء" + }, + "l10n_app_changelog_shortcuts_export_duplicates_fixed_nutrition_mood_a5dcdc11": { + "en": "Shortcuts-export duplicates fixed; nutrition & mood reach Android charts", + "ar": "إصلاح تكرار تصدير الاختصارات؛ والتغذية والمزاج يصلان إلى رسوم Android" + }, + "l10n_app_changelog_shortcuts_on_mac_recovery_in_the_dbcef46a": { + "en": "Shortcuts on Mac, recovery in the Android notification", + "ar": "الاختصارات على Mac، والتعافي في إشعار Android" + }, + "l10n_app_changelog_skin_temperature_unblocked_on_mac_ios_9cb6276c": { + "en": "Skin temperature unblocked on Mac/iOS, plus export fixes", + "ar": "حرارة الجلد متاحة على Mac/iOS، مع إصلاحات التصدير" + }, + "l10n_app_changelog_sleep_and_recovery_for_whoop_4_c8f44a56": { + "en": "Sleep and recovery for WHOOP 4.0 straps on the firmware we couldn\\'t read", + "ar": "النوم والتعافي لأساور WHOOP 4.0 على البرنامج الثابت الذي تعذّرت قراءته" + }, + "l10n_app_changelog_sleep_charge_and_workouts_cleaned_up_e21a5381": { + "en": "Sleep, Charge and workouts, cleaned up", + "ar": "النوم والشحن والتمارين، بعد تنظيفها" + }, + "l10n_app_changelog_sleep_debt_daytime_stress_a_recovery_a92853f3": { + "en": "Sleep-debt, daytime stress, a recovery forecast, and day-by-day navigation", + "ar": "دين النوم، وتوتر النهار، وتوقّع التعافي، والتنقل يومًا بيوم" + }, + "l10n_app_changelog_sleep_figures_hr_zones_charging_calibration_b209b698": { + "en": "Sleep figures, HR zones, charging & calibration - a big community-driven update", + "ar": "أرقام النوم، ومناطق النبض، والشحن والمعايرة — تحديث كبير بقيادة المجتمع" + }, + "l10n_app_changelog_sleep_fix_for_whoop_4_0_c7a1b13d": { + "en": "Sleep fix for WHOOP 4.0 + accurate WHOOP 5/MG steps", + "ar": "إصلاح النوم لـ WHOOP 4.0 + خطوات دقيقة لـ WHOOP 5/MG" + }, + "l10n_app_changelog_sleep_from_whoop_4_on_more_efe5fd60": { + "en": "Sleep from WHOOP 4 on more firmware (Mac)", + "ar": "النوم من WHOOP 4 على مزيد من البرامج الثابتة (Mac)" + }, + "l10n_app_changelog_sleep_keep_real_nights_when_the_0acb206a": { + "en": "Sleep: keep real nights when the strap comes off", + "ar": "النوم: الاحتفاظ بالليالي الحقيقية عند خلع السوار" + }, + "l10n_app_changelog_sleep_properly_sorted_and_an_app_f5e348f0": { + "en": "Sleep, properly sorted, and an app that explains itself", + "ar": "النوم، مرتَّب كما يجب، وتطبيق يشرح نفسه" + }, + "l10n_app_changelog_sleep_stages_heal_themselves_after_a_e64a730f": { + "en": "Sleep stages heal themselves after a sync", + "ar": "مراحل النوم تصحّح نفسها بعد المزامنة" + }, + "l10n_app_changelog_sleep_that_was_stuck_in_the_1b6d1307": { + "en": "Sleep that was stuck in the archive comes back", + "ar": "النوم العالق في الأرشيف يعود" + }, + "l10n_app_changelog_smart_alarm_actually_works_on_android_c9bbf50a": { + "en": "Smart alarm actually works on Android", + "ar": "المنبّه الذكي يعمل فعلًا على Android" + }, + "l10n_app_changelog_smart_alarm_the_time_you_set_6f5d260e": { + "en": "Smart alarm: the time you set is the time that fires", + "ar": "المنبّه الذكي: الوقت الذي تضبطه هو الوقت الذي يرنّ فيه" + }, + "l10n_app_changelog_smoother_an_oura_live_hr_fix_e820bfb4": { + "en": "Smoother, an Oura live-HR fix, and a big pile of improvements", + "ar": "أكثر سلاسة، وإصلاح نبض Oura المباشر، وكومة كبيرة من التحسينات" + }, + "l10n_app_changelog_smoother_during_long_history_syncs_mac_b31b616c": { + "en": "Smoother during long history syncs (Mac)", + "ar": "أكثر سلاسة أثناء مزامنات السجل الطويلة (Mac)" + }, + "l10n_app_changelog_smoother_explore_charts_and_a_clearer_9598655d": { + "en": "Smoother Explore charts, and a clearer way to connect a WHOOP 5.0/MG", + "ar": "رسوم استكشاف أكثر سلاسة، وطريقة أوضح لتوصيل WHOOP 5.0/MG" + }, + "l10n_app_changelog_spanish_whoop_exports_now_import_6351a8c6": { + "en": "Spanish WHOOP exports now import", + "ar": "ملفات تصدير WHOOP الإسبانية تُستورد الآن" + }, + "l10n_app_changelog_split_sleep_every_block_counted_one_3106596d": { + "en": "Split sleep: every block counted, one night per day", + "ar": "النوم المجزّأ: احتساب كل كتلة، وليلة واحدة لكل يوم" + }, + "l10n_app_changelog_stability_polish_for_v5_7393eaf0": { + "en": "Stability & polish for v5", + "ar": "استقرار وتحسينات للإصدار 5" + }, + "l10n_app_changelog_start_a_workout_from_the_workouts_3c882d79": { + "en": "Start a workout from the Workouts screen, and an honest Smart-alarm note", + "ar": "بدء تمرين من شاشة التمارين، وملاحظة صادقة عن المنبّه الذكي" + }, + "l10n_app_changelog_stays_connected_in_the_background_a6b0a94e": { + "en": "Stays connected in the background", + "ar": "يبقى متصلًا في الخلفية" + }, + "l10n_app_changelog_steadier_bluetooth_on_congested_android_phones_699ad7d3": { + "en": "Steadier Bluetooth on congested Android phones", + "ar": "‏Bluetooth أكثر ثباتًا على هواتف Android المزدحمة" + }, + "l10n_app_changelog_steadier_connections_fixes_and_a_nicer_fe1afd7d": { + "en": "Steadier connections, fixes, and a nicer sleep view", + "ar": "اتصالات أكثر ثباتًا، وإصلاحات، وعرض نوم أجمل" + }, + "l10n_app_changelog_steadier_heart_rate_a_stack_of_e6026074": { + "en": "Steadier heart rate + a stack of fixes", + "ar": "نبض أكثر ثباتًا + مجموعة إصلاحات" + }, + "l10n_app_changelog_stop_losing_strap_history_we_can_0598d11a": { + "en": "Stop losing strap history we can\\'t yet decode - plus a board of fixes", + "ar": "التوقف عن فقدان سجل السوار الذي لا نستطيع فكّ ترميزه بعد — مع لوحة إصلاحات" + }, + "l10n_app_changelog_strap_battery_alerts_11f79bda": { + "en": "Strap battery alerts", + "ar": "تنبيهات بطارية السوار" + }, + "l10n_app_changelog_strap_log_export_on_mac_a_e15f6b60": { + "en": "Strap-log export on Mac + a Health Monitor fix", + "ar": "تصدير سجل السوار على Mac + إصلاح Health Monitor" + }, + "l10n_app_changelog_strap_log_stays_off_the_system_b8ce2ff5": { + "en": "Strap log stays off the system log (Android)", + "ar": "سجل السوار لم يعد يظهر في سجل النظام (Android)" + }, + "l10n_app_changelog_switch_between_your_whoop_4_and_f5036092": { + "en": "Switch between your WHOOP 4 and 5.0 (Mac + Android)", + "ar": "التبديل بين WHOOP 4 و5.0 (Mac + Android)" + }, + "l10n_app_changelog_switching_between_whoop_straps_now_actually_8428faaa": { + "en": "Switching between WHOOP straps now actually switches", + "ar": "التبديل بين أساور WHOOP يبدّل فعلًا الآن" + }, + "l10n_app_changelog_sync_diagnostics_surfacing_silently_dropped_history_9fe62836": { + "en": "Sync diagnostics: surfacing silently-dropped history", + "ar": "تشخيصات المزامنة: إظهار السجل المفقود بصمت" + }, + "l10n_app_changelog_sync_visibility_a_sharper_stress_timeline_37e1e6d6": { + "en": "Sync visibility + a sharper Stress timeline", + "ar": "وضوح المزامنة + جدول زمني أدق للتوتر" + }, + "l10n_app_changelog_the_archived_sleep_recovery_now_reaches_cf4bb3ef": { + "en": "The archived-sleep recovery now reaches Android too", + "ar": "استرجاع النوم المؤرشف وصل إلى Android أيضًا" + }, + "l10n_app_changelog_the_everything_update_5e08d257": { + "en": "The everything update", + "ar": "تحديث كل شيء" + }, + "l10n_app_changelog_the_new_look_everywhere_plus_sleep_5793dddf": { + "en": "The new look everywhere - plus sleep, Effort & Bluetooth fixes", + "ar": "المظهر الجديد في كل مكان — مع إصلاحات النوم والجهد وBluetooth" + }, + "l10n_app_changelog_the_new_today_screen_comes_to_69dfab3d": { + "en": "The new Today screen comes to Android", + "ar": "شاشة «اليوم» الجديدة تصل إلى Android" + }, + "l10n_app_changelog_the_smoothness_release_faster_everywhere_plus_baf8a7fc": { + "en": "The smoothness release: faster everywhere, plus a sleep memory fix", + "ar": "إصدار السلاسة: أسرع في كل مكان، مع إصلاح ذاكرة النوم" + }, + "l10n_app_changelog_the_test_centre_help_us_fix_af39aa17": { + "en": "The Test Centre: help us fix YOUR specific problem", + "ar": "مركز الاختبار: ساعدنا في إصلاح مشكلتك أنت تحديدًا" + }, + "l10n_app_changelog_tidier_today_gauges_5d1fe894": { + "en": "Tidier Today gauges", + "ar": "مؤشرات «اليوم» أكثر ترتيبًا" + }, + "l10n_app_changelog_tidier_today_hero_strap_renaming_smarter_34493c9c": { + "en": "Tidier Today hero, strap renaming, smarter journal", + "ar": "صدارة «اليوم» أكثر ترتيبًا، وإعادة تسمية السوار، ويوميات أذكى" + }, + "l10n_app_changelog_tidier_workout_names_correct_rest_duration_135cf67f": { + "en": "Tidier workout names, correct Rest duration", + "ar": "أسماء تمارين أكثر ترتيبًا، ومدة راحة صحيحة" + }, + "l10n_app_changelog_tidy_your_journal_remove_and_hide_aa1c0b94": { + "en": "Tidy your journal - remove and hide questions", + "ar": "رتّب يومياتك — احذف الأسئلة وأخفِها" + }, + "l10n_app_changelog_times_follow_your_12_24_hour_9c552783": { + "en": "Times follow your 12-/24-hour setting", + "ar": "الأوقات تتبع إعداد 12/24 ساعة لديك" + }, + "l10n_app_changelog_today_header_date_fix_west_of_e5e7422d": { + "en": "Today header date fix (west-of-UTC)", + "ar": "إصلاح تاريخ رأس «اليوم» (غرب UTC)" + }, + "l10n_app_changelog_today_reflects_today_not_stale_imports_1a341a21": { + "en": "Today reflects today (not stale imports)", + "ar": "«اليوم» يعكس اليوم (لا عمليات استيراد قديمة)" + }, + "l10n_app_changelog_today_s_effort_goes_live_plus_5f810b61": { + "en": "Today\\'s Effort goes live - plus sleep & alarm honesty", + "ar": "جهد اليوم يصبح مباشرًا — مع صدق في النوم والمنبّه" + }, + "l10n_app_changelog_today_s_effort_no_longer_drops_f49a3428": { + "en": "Today\\'s Effort no longer drops to zero", + "ar": "جهد اليوم لم يعد يهبط إلى الصفر" + }, + "l10n_app_changelog_today_tiles_no_longer_cut_their_3c167cc1": { + "en": "Today tiles no longer cut their value to \"10…\"", + "ar": "بطاقات «اليوم» لم تعد تقتطع قيمتها إلى «10…»" + }, + "l10n_app_changelog_today_tiles_no_longer_truncate_their_2959ce81": { + "en": "Today tiles no longer truncate their value (Android)", + "ar": "بطاقات «اليوم» لم تعد تقتطع قيمتها (Android)" + }, + "l10n_app_changelog_today_trends_stay_within_their_window_ba2e4c57": { + "en": "Today trends stay within their window (Mac)", + "ar": "اتجاهات «اليوم» تبقى ضمن نافذتها (Mac)" + }, + "l10n_app_changelog_toggle_the_live_hr_dynamic_island_d3bd70cb": { + "en": "Toggle the live-HR Dynamic Island", + "ar": "مفتاح Dynamic Island للنبض المباشر" + }, + "l10n_app_changelog_track_a_workout_manually_2eb43402": { + "en": "Track a workout manually", + "ar": "تتبّع تمرين يدويًا" + }, + "l10n_app_changelog_training_load_a_vo_max_without_f9205a67": { + "en": "Training load, a VO₂max without a tape measure, and far less battery spent re-scoring", + "ar": "حمل التدريب، وVO₂max دون شريط قياس، وبطارية أقل بكثير في إعادة الاحتساب" + }, + "l10n_app_changelog_trends_report_explains_its_scores_e338d1ee": { + "en": "Trends report explains its scores", + "ar": "تقرير الاتجاهات يشرح درجاته" + }, + "l10n_app_changelog_true_battery_a_sync_indicator_and_89037fc3": { + "en": "True battery %, a sync indicator, and HR on imported workouts", + "ar": "نسبة بطارية حقيقية %، ومؤشر مزامنة، ونبض في التمارين المستوردة" + }, + "l10n_app_changelog_two_quick_fixes_the_blue_titanium_4c021d78": { + "en": "Two quick fixes: the Blue Titanium icon, and Mac \"Your Cards\"", + "ar": "إصلاحان سريعان: أيقونة Blue Titanium، و«بطاقاتك» على Mac" + }, + "l10n_app_changelog_under_the_hood_current_api_migration_672da037": { + "en": "Under-the-hood: current-API migration (no behaviour change)", + "ar": "تحت الغطاء: الانتقال إلى واجهات البرمجة الحالية (دون تغيير في السلوك)" + }, + "l10n_app_changelog_universal_mac_build_iphone_import_fix_363425a0": { + "en": "Universal Mac build + iPhone import fix", + "ar": "إصدار Mac شامل + إصلاح استيراد iPhone" + }, + "l10n_app_changelog_update_check_shows_what_s_new_6779c2e3": { + "en": "Update check shows what\\'s new", + "ar": "التحقق من التحديثات يعرض ما الجديد" + }, + "l10n_app_changelog_updates_check_github_again_1e00ced1": { + "en": "Updates check GitHub again", + "ar": "التحديثات تتحقق من GitHub مجددًا" + }, + "l10n_app_changelog_v5_the_raw_signal_release_noop_c109e3f2": { + "en": "v5 - the raw-signal release: NOOP reads the signal, on your device, free", + "ar": "الإصدار 5 — إصدار الإشارة الخام: NOOP يقرأ الإشارة، على جهازك، مجانًا" + }, + "l10n_app_changelog_water_and_caffeine_from_apple_health_5123b6b0": { + "en": "Water and caffeine from Apple Health, a sharper Effort score, and an Oura resting-heart-rate fix", + "ar": "الماء والكافيين من Apple Health، ودرجة جهد أدق، وإصلاح نبض الراحة في Oura" + }, + "l10n_app_changelog_week_in_review_is_honest_about_4c225bd3": { + "en": "Week in Review is honest about a half-finished week", + "ar": "مراجعة الأسبوع صادقة بشأن أسبوع غير مكتمل" + }, + "l10n_app_changelog_whoop_4_0_is_the_supported_16893d9d": { + "en": "WHOOP 4.0 is the supported path", + "ar": "‏WHOOP 4.0 هو المسار المدعوم" + }, + "l10n_app_changelog_whoop_5_0_history_decoding_comes_a8848386": { + "en": "WHOOP 5.0 history decoding comes to Android", + "ar": "فكّ ترميز سجل WHOOP 5.0 يصل إلى Android" + }, + "l10n_app_changelog_whoop_5_0_mg_buzz_the_c1ab45a8": { + "en": "WHOOP 5.0/MG buzz - the real command (matched byte-for-byte)", + "ar": "اهتزاز WHOOP 5.0/MG — الأمر الحقيقي (مطابق بايت ببايت)" + }, + "l10n_app_changelog_whoop_5_0_mg_buzz_trying_98373c44": { + "en": "WHOOP 5.0/MG buzz - trying the right command (experimental)", + "ar": "اهتزاز WHOOP 5.0/MG — تجربة الأمر الصحيح (تجريبي)" + }, + "l10n_app_changelog_whoop_5_0_mg_history_download_36cc4425": { + "en": "WHOOP 5.0/MG history download (experimental) + pairing help (Mac)", + "ar": "تنزيل سجل WHOOP 5.0/MG (تجريبي) + مساعدة الاقتران (Mac)" + }, + "l10n_app_changelog_whoop_5_0_mg_history_offload_76f5af44": { + "en": "WHOOP 5.0/MG history offload (Android)", + "ar": "تفريغ سجل WHOOP 5.0/MG (Android)" + }, + "l10n_app_changelog_whoop_5_mg_deep_data_live_2612bf3d": { + "en": "WHOOP 5/MG deep data: live confirmation it\\'s working", + "ar": "البيانات العميقة لـ WHOOP 5/MG: تأكيد مباشر أنها تعمل" + }, + "l10n_app_changelog_whoop_5_mg_deep_sync_decode_652c34d2": { + "en": "WHOOP 5/MG deep-sync decode + sleep & workout fixes", + "ar": "فكّ ترميز المزامنة العميقة لـ WHOOP 5/MG + إصلاحات النوم والتمارين" + }, + "l10n_app_changelog_whoop_5_mg_frame_capture_mac_9e84ee52": { + "en": "WHOOP 5/MG frame capture (Mac)", + "ar": "التقاط إطارات WHOOP 5/MG (Mac)" + }, + "l10n_app_changelog_whoop_5_mg_heart_rate_on_11816e98": { + "en": "WHOOP 5/MG heart rate on Android", + "ar": "نبض WHOOP 5/MG على Android" + }, + "l10n_app_changelog_whoop_5_mg_heart_rate_on_b66c6752": { + "en": "WHOOP 5/MG heart rate on Mac + a Readiness fix", + "ar": "نبض WHOOP 5/MG على Mac + إصلاح الجاهزية" + }, + "l10n_app_changelog_whoop_5_mg_history_the_missing_b4d5db99": { + "en": "WHOOP 5/MG history: the missing clock", + "ar": "سجل WHOOP 5/MG: الساعة المفقودة" + }, + "l10n_app_changelog_whoop_5_mg_secure_pairing_fix_ed6550f3": { + "en": "WHOOP 5/MG: secure-pairing fix", + "ar": "‏WHOOP 5/MG: إصلاح الاقتران الآمن" + }, + "l10n_app_changelog_whoop_5_mg_the_buzz_works_a0308753": { + "en": "WHOOP 5/MG: the buzz works", + "ar": "‏WHOOP 5/MG: الاهتزاز يعمل" + }, + "l10n_app_changelog_whoop_style_hrv_warm_ups_counted_199aaa42": { + "en": "WHOOP-style HRV, warm-ups counted, and clearer cards", + "ar": "تقلب نبض بأسلوب WHOOP، واحتساب الإحماء، وبطاقات أوضح" + }, + "l10n_app_changelog_widgets_stop_inventing_numbers_naps_count_4f7491bb": { + "en": "Widgets stop inventing numbers, naps count toward sleep debt, and the Android status chips speak your language", + "ar": "الأدوات تتوقف عن اختلاق الأرقام، والقيلولات تُحتسب في دين النوم، ومؤشرات Android تتحدث لغتك" + }, + "l10n_app_changelog_workout_calories_for_manual_sessions_and_5d61f59a": { + "en": "Workout calories - for manual sessions and Health Connect imports", + "ar": "سعرات التمرين — للجلسات اليدوية وعمليات استيراد Health Connect" + }, + "l10n_app_changelog_workout_display_fixes_255b56e8": { + "en": "Workout display fixes", + "ar": "إصلاحات عرض التمارين" + }, + "l10n_app_changelog_workouts_correct_source_pill_for_health_787243ea": { + "en": "Workouts: correct source pill for Health Connect (Android)", + "ar": "التمارين: مؤشر مصدر صحيح لـ Health Connect (Android)" + }, + "l10n_app_changelog_workouts_header_layout_fix_phone_fa23fbec": { + "en": "Workouts header layout fix (phone)", + "ar": "إصلاح تخطيط رأس «التمارين» (الهاتف)" + }, + "l10n_app_changelog_wrist_alerts_for_incoming_calls_android_9bf80a06": { + "en": "Wrist alerts for incoming calls (Android)", + "ar": "تنبيهات المعصم للمكالمات الواردة (Android)" + }, + "l10n_app_changelog_wrist_alerts_work_on_android_09e544a9": { + "en": "Wrist alerts work on Android", + "ar": "تنبيهات المعصم تعمل على Android" + }, + "l10n_app_changelog_your_fitness_age_vitality_body_age_8e9867fb": { + "en": "Your Fitness Age, Vitality & Body Age", + "ar": "عمرك اللياقي، والحيوية، وعمر الجسد" + }, + "l10n_app_changelog_your_imported_steps_now_show_on_d79f0b36": { + "en": "Your imported steps now show on the Today screen (Android)", + "ar": "خطواتك المستوردة تظهر الآن في شاشة «اليوم» (Android)" + }, + "l10n_app_changelog_your_macos_data_is_back_and_e5e3e3bd": { + "en": "Your macOS data is back — and full French", + "ar": "بيانات macOS عادت — وفرنسية كاملة" + }, + "l10n_app_changelog_your_scores_build_over_a_few_41388c54": { + "en": "Your scores build over a few nights", + "ar": "درجاتك تتكوّن عبر بضع ليالٍ" + }, + "l10n_app_changelog_your_whole_day_s_heart_rate_e57593f8": { + "en": "Your whole day\\'s heart rate, on the dashboard", + "ar": "نبض يومك كاملًا، على اللوحة" + }, + "l10n_app_changelog_your_whoop_journal_in_insights_clearer_88f5e21b": { + "en": "Your WHOOP journal in Insights, clearer metric taps", + "ar": "يوميات WHOOP في الرؤى، ونقرات مقاييس أوضح" + }, + "l10n_app_root_more_4bab2d8f": { + "en": "More", + "ar": "المزيد" + }, + "l10n_app_root_this_section_is_on_the_way_ca7c4a32": { + "en": "This section is on the way.", + "ar": "هذا القسم قادم قريبًا." + }, + "l10n_app_root_updates_c76d1807": { + "en": "Updates", + "ar": "التحديثات" + }, + "l10n_apple_health_screen_no_readings_recorded_05018b26": { + "en": "No readings recorded.", + "ar": "لا قراءات مسجّلة." + }, + "l10n_apple_health_screen_nothing_imported_yet_f457cdbe": { + "en": "Nothing imported yet", + "ar": "لم يُستورد شيء بعد" + }, + "l10n_apple_health_screen_reading_your_apple_health_history_1a3bf76d": { + "en": "Reading your Apple Health history…", + "ar": "جارٍ قراءة سجل Apple Health…" + }, + "l10n_auto_workout_nudge_dismiss_this_workout_suggestion_52ace8f3": { + "en": "Dismiss this workout suggestion", + "ar": "تجاهل اقتراح التمرين هذا" + }, + "l10n_auto_workout_nudge_looks_like_a_workout_e745e403": { + "en": "Looks like a workout", + "ar": "يبدو أنه تمرين" + }, + "l10n_auto_workout_nudge_not_a_workout_15c5f784": { + "en": "Not a workout", + "ar": "ليس تمرينًا" + }, + "l10n_auto_workout_nudge_save_it_01d23661": { + "en": "Save it", + "ar": "احفظه" + }, + "l10n_automations_screen_automations_82542d6d": { + "en": "Automations", + "ar": "الأتمتة" + }, + "l10n_automations_screen_battery_alerts_f3679d60": { + "en": "Battery alerts", + "ar": "تنبيهات البطارية" + }, + "l10n_automations_screen_buzz_strength_e895f99e": { + "en": "Buzz strength", + "ar": "قوة الاهتزاز" + }, + "l10n_automations_screen_choose_double_tap_action_6ac9a313": { + "en": "Choose double-tap action", + "ar": "اختر إجراء النقر المزدوج" + }, + "l10n_automations_screen_detect_short_naps_bbfd136d": { + "en": "Detect short naps", + "ar": "اكتشاف القيلولات القصيرة" + }, + "l10n_automations_screen_double_tap_8d2f1646": { + "en": "Double-tap", + "ar": "نقر مزدوج" + }, + "l10n_automations_screen_each_day_uses_the_time_above_f9bc9ce3": { + "en": "Each day uses the time above unless you set a different one here.", + "ar": "يستخدم كل يوم الوقت أعلاه ما لم تضبط وقتًا مختلفًا هنا." + }, + "l10n_automations_screen_enable_inactivity_reminder_468c3017": { + "en": "Enable inactivity reminder", + "ar": "تفعيل تذكير الخمول" + }, + "l10n_automations_screen_from_3f66052a": { + "en": "From", + "ar": "من" + }, + "l10n_automations_screen_haptic_coaching_e2fab286": { + "en": "Haptic coaching", + "ar": "إرشاد بالاهتزاز" + }, + "l10n_automations_screen_hr_zone_coaching_9306e6e1": { + "en": "HR-zone coaching", + "ar": "إرشاد مناطق النبض" + }, + "l10n_automations_screen_illness_early_warning_453ab477": { + "en": "Illness early-warning", + "ar": "إنذار مبكر بالمرض" + }, + "l10n_automations_screen_inactivity_reminder_ca49b1ba": { + "en": "Inactivity reminder", + "ar": "تذكير الخمول" + }, + "l10n_automations_screen_nap_detection_ca2dedf5": { + "en": "Nap detection", + "ar": "اكتشاف القيلولة" + }, + "l10n_automations_screen_no_naps_to_review_detected_naps_2e82e9fc": { + "en": "No naps to review. Detected naps show up here after a history sync.", + "ar": "لا قيلولات للمراجعة. تظهر القيلولات المكتشفة هنا بعد مزامنة السجل." + }, + "l10n_automations_screen_notifications_are_off_so_this_can_b3dba7ee": { + "en": "Notifications are off, so this can\\'t buzz yet. Turn on the master switch in ", + "ar": "الإشعارات مغلقة، لذا لا يمكن أن يهتز هذا بعد. فعّل المفتاح الرئيسي في " + }, + "l10n_automations_screen_notify_on_low_and_full_battery_d1903bb8": { + "en": "Notify on low and full battery", + "ar": "تنبيه عند انخفاض البطارية واكتمالها" + }, + "l10n_automations_screen_only_during_active_hours_29c53fc9": { + "en": "Only during active hours", + "ar": "أثناء ساعات النشاط فقط" + }, + "l10n_automations_screen_per_day_wake_time_873c81e1": { + "en": "Per-day wake time", + "ar": "وقت الاستيقاظ لكل يوم" + }, + "l10n_automations_screen_predictive_runtime_warning_4d85f5a6": { + "en": "Predictive runtime warning", + "ar": "تحذير تنبؤي بمدة التشغيل" + }, + "l10n_automations_screen_re_nudge_every_646023cd": { + "en": "Re-nudge every", + "ar": "إعادة التذكير كل" + }, + "l10n_automations_screen_recovery_buzz_1abc9a51": { + "en": "Recovery buzz", + "ar": "اهتزاز التعافي" + }, + "l10n_automations_screen_reset_44c57abd": { + "en": "Reset", + "ar": "إعادة تعيين" + }, + "l10n_automations_screen_sitting_for_e464c472": { + "en": "Sitting for", + "ar": "الجلوس لمدة" + }, + "l10n_automations_screen_test_action_266df314": { + "en": "Test action", + "ar": "اختبار الإجراء" + }, + "l10n_automations_screen_watch_for_early_illness_signs_4c22e127": { + "en": "Watch for early-illness signs", + "ar": "مراقبة علامات المرض المبكرة" + }, + "l10n_automations_screen_when_i_double_tap_a1f1e04d": { + "en": "When I double-tap", + "ar": "عند النقر المزدوج" + }, + "l10n_backup_sync_screen_auto_backup_hasn_t_run_in_e3cd484e": { + "en": "Auto-backup hasn\\'t run in a few days. Check the backup folder is still available — a moved or disconnected cloud folder stops backups silently.", + "ar": "لم يعمل النسخ الاحتياطي التلقائي منذ عدة أيام. تأكد من أن مجلد النسخ ما زال متاحًا — فالمجلد السحابي المنقول أو غير المتصل يوقف النسخ بصمت." + }, + "l10n_backup_sync_screen_backup_folder_2a33df93": { + "en": "Backup folder", + "ar": "مجلد النسخ الاحتياطي" + }, + "l10n_backup_sync_screen_backup_sync_81758ffa": { + "en": "Backup & Sync", + "ar": "النسخ الاحتياطي والمزامنة" + }, + "l10n_backup_sync_screen_backup_time_81557aaa": { + "en": "Backup time", + "ar": "وقت النسخ الاحتياطي" + }, + "l10n_backup_sync_screen_cancel_77dfd213": { + "en": "Cancel", + "ar": "إلغاء" + }, + "l10n_backup_sync_screen_choose_a_backup_2fbfb0d6": { + "en": "Choose a backup", + "ar": "اختر نسخة احتياطية" + }, + "l10n_backup_sync_screen_daily_auto_backup_e5627357": { + "en": "Daily auto-backup", + "ar": "نسخ احتياطي تلقائي يومي" + }, + "l10n_backup_sync_screen_keep_last_snapshots_cd5c9ea9": { + "en": "Keep last snapshots", + "ar": "الاحتفاظ بآخر النسخ" + }, + "l10n_backup_sync_screen_newest_first_restoring_replaces_this_device_97dbcd8b": { + "en": "Newest first. Restoring replaces this device\\'s data.", + "ar": "الأحدث أولًا. الاستعادة تستبدل بيانات هذا الجهاز." + }, + "l10n_backup_sync_screen_older_backups_beyond_this_many_are_00b7daa6": { + "en": "Older backups beyond this many are pruned, oldest first (≈ that many days of ", + "ar": "تُحذف النسخ الأقدم بعد هذا العدد، بدءًا من الأقدم (≈ هذا العدد من الأيام من " + }, + "l10n_backup_sync_screen_replace_a7cf7b25": { + "en": "Replace", + "ar": "استبدال" + }, + "l10n_backup_sync_screen_replace_all_current_data_e9244799": { + "en": "Replace all current data?", + "ar": "استبدال كل البيانات الحالية؟" + }, + "l10n_backup_sync_screen_replace_all_current_data_with_label_b7799a16": { + "en": "Replace all current data with %1$s? This cannot be undone.", + "ar": "استبدال كل البيانات الحالية بـ %1$s؟ لا يمكن التراجع عن ذلك." + }, + "l10n_backup_sync_screen_replace_this_device_s_data_with_b8679c51": { + "en": "Replace this device\\'s data with one of your backups. This overwrites current data, ", + "ar": "استبدل بيانات هذا الجهاز بإحدى نسخك الاحتياطية. يستبدل هذا البيانات الحالية، " + }, + "l10n_backup_sync_screen_restore_3cbe6d6b": { + "en": "Restore", + "ar": "استعادة" + }, + "l10n_backup_sync_screen_restore_from_a_backup_c28917c6": { + "en": "Restore from a backup…", + "ar": "الاستعادة من نسخة احتياطية…" + }, + "l10n_backup_sync_screen_roughly_when_the_daily_backup_runs_71de04e1": { + "en": "Roughly when the daily backup runs (best-effort — the system may slide it a little).", + "ar": "تقريبًا متى يعمل النسخ اليومي (بأفضل جهد — قد يؤجّله النظام قليلًا)." + }, + "l10n_backup_sync_screen_these_backups_are_unencrypted_too_if_53e70fe8": { + "en": "These backups are unencrypted too. If this folder syncs to Drive, Dropbox or iCloud, the readable file goes there as well — only point it at a service you trust.", + "ar": "هذه النسخ غير مشفّرة أيضًا. إذا كان هذا المجلد يتزامن مع Drive أو Dropbox أو iCloud، فسينتقل الملف المقروء إلى هناك كذلك — فوجّهه إلى خدمة تثق بها فقط." + }, + "l10n_backup_sync_screen_tip_a_desktop_drive_dropbox_app_2eaff1e3": { + "en": "Tip: a desktop Drive / Dropbox app auto-syncs a chosen folder. On the phone, save to a ", + "ar": "نصيحة: تطبيق Drive / Dropbox على سطح المكتب يزامن مجلدًا تختاره تلقائيًا. على الهاتف، احفظ في " + }, + "l10n_backup_sync_screen_writes_a_fresh_dated_backup_to_bd964fc5": { + "en": "Writes a fresh dated backup to your folder once a day at the time below, keeping ", + "ar": "يكتب نسخة احتياطية جديدة مؤرخة في مجلدك مرة يوميًا في الوقت أدناه، مع الاحتفاظ بـ " + }, + "l10n_body_clock_dial_card_1_s_earlier_than_your_body_68877f50": { + "en": "%1$s earlier than your body clock", + "ar": "أبكر بـ %1$s من ساعتك البيولوجية" + }, + "l10n_body_clock_dial_card_1_s_later_than_your_body_50b7d966": { + "en": "%1$s later than your body clock", + "ar": "أمتأخر بـ %1$s عن ساعتك البيولوجية" + }, + "l10n_body_clock_dial_card_body_clock_b0b9b988": { + "en": "Body clock", + "ar": "الساعة البيولوجية" + }, + "l10n_body_clock_dial_card_body_clock_dial_03cdbc31": { + "en": "Body clock dial", + "ar": "قرص الساعة البيولوجية" + }, + "l10n_body_clock_dial_card_evening_type_3e1ce111": { + "en": "Evening type", + "ar": "نمط مسائي" + }, + "l10n_body_clock_dial_card_in_sync_with_your_body_clock_4b5181c8": { + "en": "In sync with your body clock", + "ar": "متوافق مع ساعتك البيولوجية" + }, + "l10n_body_clock_dial_card_intermediate_type_a7b6b74c": { + "en": "Intermediate type", + "ar": "نمط متوسط" + }, + "l10n_body_clock_dial_card_last_night_6eaba4dd": { + "en": "Last night", + "ar": "الليلة الماضية" + }, + "l10n_body_clock_dial_card_last_night_against_your_clock_9183a37c": { + "en": "Last night against your clock", + "ar": "الليلة الماضية مقابل ساعتك" + }, + "l10n_body_clock_dial_card_morning_type_289dd7bf": { + "en": "Morning type", + "ar": "نمط صباحي" + }, + "l10n_body_clock_dial_card_your_clock_1b91f5ee": { + "en": "Your clock", + "ar": "ساعتك" + }, + "l10n_breathe_screen_a_relaxation_rhythm_not_cardiac_control_5c2f10a8": { + "en": "A relaxation rhythm, not cardiac control. It never paces below a safe rate and you can stop anytime. If your heart rate doesn\\'t settle, we\\'ll say so plainly.", + "ar": "إيقاع للاسترخاء، لا تحكّم في القلب. لا يهبط بالإيقاع دون معدل آمن ويمكنك التوقف في أي وقت. وإذا لم يهدأ نبضك، سنقول ذلك بوضوح." + }, + "l10n_breathe_screen_about_pace_o3p4q5r6": { + "en": "About this pace", + "ar": "عن هذا الإيقاع" + }, + "l10n_breathe_screen_audio_cues_74430aec": { + "en": "Audio cues", + "ar": "تنبيهات صوتية" + }, + "l10n_breathe_screen_breathcount_breaths_ce036831": { + "en": "%1$s breaths", + "ar": "%1$s نفَس" + }, + "l10n_breathe_screen_breathe_282be568": { + "en": "Breathe", + "ar": "التنفس" + }, + "l10n_breathe_screen_breathe_in_a5b6c7d8": { + "en": "Breathe in…", + "ar": "شهيق…" + }, + "l10n_breathe_screen_breathe_now_98d6c341": { + "en": "Breathe now", + "ar": "تنفّس الآن" + }, + "l10n_breathe_screen_breathe_out_e9f0a1b2": { + "en": "Breathe out…", + "ar": "زفير…" + }, + "l10n_breathe_screen_calm_me_3_min_11250c77": { + "en": "Calm me · 3 min", + "ar": "هدّئني · 3 دقائق" + }, + "l10n_breathe_screen_calm_me_a1b2c3d4": { + "en": "Calm me", + "ar": "هدّئني" + }, + "l10n_breathe_screen_connect_your_strap_calm_me_is_71314744": { + "en": "Connect your strap. Calm me is a felt rhythm on the wrist, so it needs a bonded connection.", + "ar": "وصّل سوارك. «هدّئني» إيقاع محسوس على المعصم، لذا يحتاج إلى اتصال مقترن." + }, + "l10n_breathe_screen_connect_your_strap_for_haptic_guidance_65660684": { + "en": "Connect your strap for haptic guidance. You\\'ll feel one pulse on the inhale, two on the exhale, so you can breathe with your eyes closed.", + "ar": "وصّل سوارك للإرشاد بالاهتزاز. ستشعر بنبضة واحدة عند الشهيق ونبضتين عند الزفير، لتتنفس وعيناك مغمضتان." + }, + "l10n_breathe_screen_disclaimer_w1x2y3z4": { + "en": "Estimate only — not medical advice. Stop if you feel unwell.", + "ar": "تقدير فقط — ليس نصيحة طبية. توقف إذا شعرت بتوعك." + }, + "l10n_breathe_screen_done_s7t8u9v0": { + "en": "Done", + "ar": "تم" + }, + "l10n_breathe_screen_estimate_from_ppg_derived_r_r_9c92fbae": { + "en": "Estimate from PPG-derived R-R: relaxation guidance, not a clinical reading. Your pace drifts, so we date it and you can re-measure anytime.", + "ar": "تقدير من فترات R-R المشتقة من PPG: إرشاد للاسترخاء وليس قراءة سريرية. إيقاعك يتغير، لذا نؤرّخه ويمكنك إعادة القياس متى شئت." + }, + "l10n_breathe_screen_estimate_only_a_higher_rmssd_while_bfd71bb8": { + "en": "Estimate only: a higher RMSSD while paced usually means your parasympathetic \"rest\" branch is engaging. It is not a clinical reading; trends over a session matter more than any single number.", + "ar": "تقدير فقط: ارتفاع RMSSD أثناء الإيقاع يعني عادةً أن فرعك السمبثاوي المسؤول عن «الراحة» يعمل. ليست قراءة سريرية؛ والاتجاهات عبر الجلسة أهم من أي رقم منفرد." + }, + "l10n_breathe_screen_everyone_has_a_breathing_pace_usually_ad6ea0b4": { + "en": "Everyone has a breathing pace (usually between 4.5 and 7 breaths a minute) where the heart\\'s rhythm swings the most with each breath. We pace you through a few candidate paces, measure how your HRV responds, and lock the one that resonates best for you.", + "ar": "لكل شخص إيقاع تنفس (عادة بين 4.5 و7 أنفاس في الدقيقة) يتأرجح عنده إيقاع القلب أكثر ما يمكن مع كل نفَس. نمرّ بك عبر عدة إيقاعات مرشّحة، ونقيس استجابة تقلب النبض لديك، ونثبّت الإيقاع الأكثر رنينًا لك." + }, + "l10n_breathe_screen_follow_cue_c3d4e5f6": { + "en": "Follow the cue…", + "ar": "اتبع الإشارة…" + }, + "l10n_breathe_screen_full_sweep_13_min_08e01d2c": { + "en": "Full sweep · ~13 min", + "ar": "مسح كامل · ~13 دقيقة" + }, + "l10n_breathe_screen_guided_k9l0m1n2": { + "en": "Guided", + "ar": "موجّه" + }, + "l10n_breathe_screen_guided_timer_g7h8i9j0": { + "en": "Guided timer", + "ar": "مؤقّت موجّه" + }, + "l10n_breathe_screen_heart_rate_410aa15c": { + "en": "Heart rate", + "ar": "معدل النبض" + }, + "l10n_breathe_screen_hold_g5h6i7j8": { + "en": "Hold…", + "ar": "احبس…" + }, + "l10n_breathe_screen_hrv_rmssd_51014f87": { + "en": "HRV (RMSSD)", + "ar": "تقلب النبض (RMSSD)" + }, + "l10n_breathe_screen_locked_formatday_datems_paces_drift_re_8ebec15b": { + "en": "Locked %1$s · paces drift, re-measure anytime.", + "ar": "مثبّت %1$s · الإيقاعات تتغير، أعد القياس متى شئت." + }, + "l10n_breathe_screen_locked_formatday_datems_switch_to_breathe_3efc52c6": { + "en": "Locked %1$s. Switch to Breathe to use it, or re-measure above.", + "ar": "مثبّت %1$s. انتقل إلى «التنفس» لاستخدامه، أو أعد القياس أعلاه." + }, + "l10n_breathe_screen_locked_pace_o5p6q7r8": { + "en": "Your locked pace · %1$.1f br/min", + "ar": "إيقاعك المثبّت · %1$.1f نفَس/دقيقة" + }, + "l10n_breathe_screen_not_enough_clean_beat_data_to_865a1260": { + "en": "Not enough clean beat data to lock a pace today. Try again rested, sitting still with the strap snug. For now we\\'ll pace you at 5.5 br/min (coherence).", + "ar": "لا توجد بيانات نبض نظيفة كافية لتثبيت إيقاع اليوم. حاول مجددًا وأنت مرتاح، جالسًا دون حركة والسوار محكم. وحاليًا سنضبط إيقاعك على 5.5 نفَس/دقيقة (الاتساق)." + }, + "l10n_breathe_screen_not_now_e4571490": { + "en": "Not now", + "ar": "ليس الآن" + }, + "l10n_breathe_screen_pace_7a9a6226": { + "en": "Pace", + "ar": "الإيقاع" + }, + "l10n_breathe_screen_protocol_info_c1d2e3f4": { + "en": "Protocol info", + "ar": "معلومات البروتوكول" + }, + "l10n_breathe_screen_quick_sweep_7_min_5d58f88d": { + "en": "Quick sweep · ~7 min", + "ar": "مسح سريع · ~7 دقائق" + }, + "l10n_breathe_screen_relaxation_guidance_from_your_own_numbers_16ee0ba1": { + "en": "Relaxation guidance from your own numbers: not a health alert, and not a diagnosis. Trends matter more than any single number.", + "ar": "إرشاد للاسترخاء من أرقامك أنت: ليس تنبيهًا صحيًا ولا تشخيصًا. الاتجاهات أهم من أي رقم منفرد." + }, + "l10n_breathe_screen_resonance_edu_s9t0u1v2": { + "en": "Your locked resonance pace from the Resonance sweep.", + "ar": "إيقاع الرنين المثبّت من مسح الرنين." + }, + "l10n_breathe_screen_resonance_k1l2m3n4": { + "en": "Resonance", + "ar": "الرنين" + }, + "l10n_breathe_screen_session_10_min_a3b4c5d6": { + "en": "10 min", + "ar": "10 دقائق" + }, + "l10n_breathe_screen_session_15_min_e7f8a9b0": { + "en": "15 min", + "ar": "15 دقيقة" + }, + "l10n_breathe_screen_session_5_min_c9d0e1f2": { + "en": "5 min", + "ar": "5 دقائق" + }, + "l10n_breathe_screen_session_length_a1b2c3d4": { + "en": "Session length", + "ar": "مدة الجلسة" + }, + "l10n_breathe_screen_session_open_e5f6a7b8": { + "en": "Open", + "ar": "فتح" + }, + "l10n_breathe_screen_sign_kotlin_math_abs_trend_hrv_e9362899": { + "en": "%1$s%2$s%% HRV", + "ar": "%1$s%2$s%% تقلب النبض" + }, + "l10n_breathe_screen_sit_still_and_breathe_with_the_b7c53c40": { + "en": "Sit still and breathe with the buzz. You can stop anytime; a stopped sweep won\\'t lock a pace.", + "ar": "اجلس دون حركة وتنفّس مع الاهتزاز. يمكنك التوقف في أي وقت؛ والمسح المتوقف لن يثبّت إيقاعًا." + }, + "l10n_breathe_screen_soft_tone_on_each_phase_honours_2a02c284": { + "en": "Soft tone on each phase · honours silent mode", + "ar": "نغمة خفيفة عند كل مرحلة · تحترم الوضع الصامت" + }, + "l10n_breathe_screen_started_at_it_bpm_the_rhythm_dda6a573": { + "en": "Started at %1$s bpm · the rhythm trails your heart down.", + "ar": "بدأنا عند %1$s نبضة/دقيقة · الإيقاع يتبع هبوط قلبك." + }, + "l10n_breathe_screen_stop_9e253470": { + "en": "Stop", + "ar": "إيقاف" + }, + "l10n_breathe_screen_stop_sweep_55299cf9": { + "en": "Stop sweep", + "ar": "إيقاف المسح" + }, + "l10n_breathe_screen_test_buzz_deeab5ae": { + "en": "Test buzz", + "ar": "اختبار الاهتزاز" + }, + "l10n_breathe_screen_that_s_normal_a_paced_breath_72e2d011": { + "en": "That\\'s normal. A paced breath often settles things when a metronome alone doesn\\'t.", + "ar": "هذا طبيعي. النفَس المنتظم غالبًا ما يهدّئ حين لا يفلح المترونوم وحده." + }, + "l10n_breathe_screen_the_strap_buzzes_a_gentle_rhythm_eb5c6a65": { + "en": "The strap buzzes a gentle rhythm just below your current heart rate, a felt metronome to relax toward. It trails your heart down rather than yanking it, and stops on its own.", + "ar": "يهتز السوار بإيقاع لطيف أقل قليلًا من معدل نبضك الحالي، كمترونوم محسوس تسترخي نحوه. يتبع هبوط قلبك بدل أن يجذبه، ويتوقف من تلقاء نفسه." + }, + "l10n_breathe_screen_turn_off_8807c2b3": { + "en": "Turn off", + "ar": "إيقاف" + }, + "l10n_breathe_screen_waiting_for_a_resting_heart_rate_980cedbf": { + "en": "Waiting for a resting heart rate. Start a live reading first, or come back when you\\'re still.", + "ar": "في انتظار معدل نبض في الراحة. ابدأ قراءة مباشرة أولًا، أو عد حين تكون ساكنًا." + }, + "l10n_breathe_screen_your_hrv_dipped_while_you_were_231d3c7a": { + "en": "Your HRV dipped while you were still. Want a minute to breathe?", + "ar": "انخفض تقلب نبضك بينما كنت ساكنًا. أتريد دقيقة للتنفس؟" + }, + "l10n_caffeine_log_after_your_clocklabel_cutoffminutes_cutoff_may_3c023900": { + "en": "After your %1$s cutoff - may still be active at bed.", + "ar": "بعد حدّك %1$s — قد يظل فعّالًا عند النوم." + }, + "l10n_caffeine_log_amount_in_mg_optional_f0749888": { + "en": "Amount in mg (optional)", + "ar": "الكمية بالمليغرام (اختياري)" + }, + "l10n_caffeine_log_bedtime_e3cb8bd6": { + "en": "Bedtime", + "ar": "وقت النوم" + }, + "l10n_caffeine_log_caffeine_22859fa3": { + "en": "Caffeine", + "ar": "الكافيين" + }, + "l10n_caffeine_log_flag_drinks_late_enough_to_still_af311272": { + "en": "Flag drinks late enough to still be active at bedtime.", + "ar": "تمييز المشروبات المتأخرة بما يكفي لتظل فعّالة عند النوم." + }, + "l10n_caffeine_log_had_it_8576ac66": { + "en": "Had it", + "ar": "تناولته" + }, + "l10n_caffeine_log_have_your_last_caffeine_by_about_16b09033": { + "en": "Have your last caffeine by about %1$s to clear most of it ", + "ar": "تناول آخر كافيين قبل %1$s تقريبًا ليزول معظمه " + }, + "l10n_caffeine_log_late_caffeine_nudge_6b2ff690": { + "en": "Late-caffeine nudge", + "ar": "تنبيه الكافيين المتأخر" + }, + "l10n_caffeine_log_log_a_coffee_tea_or_energy_982bde5b": { + "en": "Log a coffee, tea, or energy drink and NOOP shows a rough estimate of how much may still be active. It\\'s a guide based on a typical 5 to 6 hour half-life, not a measurement.", + "ar": "سجّل قهوة أو شايًا أو مشروب طاقة، ويعرض NOOP تقديرًا تقريبيًا لما قد يظل فعّالًا. إنه دليل مبني على عمر نصف نموذجي من 5 إلى 6 ساعات، وليس قياسًا." + }, + "l10n_caffeine_log_logged_today_0071a46c": { + "en": "Logged today", + "ar": "سُجِّل اليوم" + }, + "l10n_caffeine_log_remove_e963907d": { + "en": "Remove", + "ar": "إزالة" + }, + "l10n_coach_screen_api_key_hidden_f3cde531": { + "en": "API key (hidden)", + "ar": "مفتاح API (مخفي)" + }, + "l10n_coach_screen_ask_anything_about_your_recent_recovery_e6c287ca": { + "en": "Ask anything about your recent recovery, strain, sleep or HRV.", + "ar": "اسأل عن أي شيء يخص تعافيك أو إجهادك أو نومك أو تقلب نبضك مؤخرًا." + }, + "l10n_coach_screen_ask_your_coach_b1577d4c": { + "en": "Ask your coach…", + "ar": "اسأل مدربك…" + }, + "l10n_coach_screen_cancel_77dfd213": { + "en": "Cancel", + "ar": "إلغاء" + }, + "l10n_coach_screen_coach_b32c9ad3": { + "en": "Coach", + "ar": "المدرب الذكي" + }, + "l10n_coach_screen_coach_error_error_ad9c8c46": { + "en": "Coach error: %1$s", + "ar": "خطأ في المدرب الذكي: %1$s" + }, + "l10n_coach_screen_coach_instructions_28a07975": { + "en": "Coach instructions", + "ar": "تعليمات المدرب الذكي" + }, + "l10n_coach_screen_coach_instructions_editor_b8f3ad31": { + "en": "Coach instructions editor", + "ar": "محرّر تعليمات المدرب الذكي" + }, + "l10n_coach_screen_coach_is_thinking_aaf91547": { + "en": "Coach is thinking", + "ar": "المدرب الذكي يفكر" + }, + "l10n_coach_screen_connect_a_provider_6967f288": { + "en": "Connect a provider", + "ar": "وصّل مزوّدًا" + }, + "l10n_coach_screen_connect_b65463cb": { + "en": "Connect", + "ar": "توصيل" + }, + "l10n_coach_screen_custom_dce04fd3": { + "en": "Custom…", + "ar": "مخصص…" + }, + "l10n_coach_screen_custom_model_2e3bedea": { + "en": "Custom model", + "ar": "نموذج مخصص" + }, + "l10n_coach_screen_custom_model_id_6ffe2740": { + "en": "Custom model id", + "ar": "معرّف نموذج مخصص" + }, + "l10n_coach_screen_disconnect_ed28e068": { + "en": "Disconnect", + "ar": "قطع الاتصال" + }, + "l10n_coach_screen_disconnect_provider_fa13625c": { + "en": "Disconnect provider", + "ar": "قطع الاتصال بالمزوّد" + }, + "l10n_coach_screen_e_g_gpt_4o_1da2e4d2": { + "en": "e.g. gpt-4o", + "ar": "مثال: gpt-4o" + }, + "l10n_coach_screen_enter_any_model_id_the_provider_dce4bbcb": { + "en": "Enter any model id the provider accepts.", + "ar": "أدخل أي معرّف نموذج يقبله المزوّد." + }, + "l10n_coach_screen_fetch_models_from_provider_6654e1a0": { + "en": "Fetch models from provider", + "ar": "جلب النماذج من المزوّد" + }, + "l10n_coach_screen_key_header_3f2a9b10": { + "en": "Key header", + "ar": "ترويسة المفتاح" + }, + "l10n_coach_screen_let_the_coach_use_my_data_405d1188": { + "en": "Let the coach use my data", + "ar": "السماح للمدرب باستخدام بياناتي" + }, + "l10n_coach_screen_model_selected_tap_to_change_043056c1": { + "en": "Model: %1$s. Tap to change.", + "ar": "النموذج: %1$s. اضغط للتغيير." + }, + "l10n_coach_screen_reset_to_default_39c90eb7": { + "en": "Reset to default", + "ar": "إعادة إلى الافتراضي" + }, + "l10n_coach_screen_save_key_f5216b3a": { + "en": "Save key", + "ar": "حفظ المفتاح" + }, + "l10n_coach_screen_send_message_c70a890d": { + "en": "Send message", + "ar": "إرسال رسالة" + }, + "l10n_coach_screen_server_url_1d5d1eff": { + "en": "Server URL", + "ar": "عنوان الخادم" + }, + "l10n_coach_screen_suggested_prompt_prompt_379c0b15": { + "en": "Suggested prompt: %1$s", + "ar": "طلب مقترح: %1$s" + }, + "l10n_coach_screen_thinking_a60d9c9c": { + "en": "Thinking…", + "ar": "يفكر…" + }, + "l10n_coach_screen_use_bearer_for_most_local_servers_4429ab64": { + "en": "Use Bearer for most local servers; use x-api-key for gateways that require the key in that header.", + "ar": "استخدم Bearer لمعظم الخوادم المحلية؛ واستخدم x-api-key للبوابات التي تتطلب المفتاح في تلك الترويسة." + }, + "l10n_coach_screen_use_model_8d558ce2": { + "en": "Use model", + "ar": "استخدام النموذج" + }, + "l10n_compare_screen_add_a_metric_to_compare_e6a7c9f1": { + "en": "Add a metric to compare", + "ar": "أضف مقياسًا للمقارنة" + }, + "l10n_compare_screen_compare_8d105cf4": { + "en": "Compare", + "ar": "مقارنة" + }, + "l10n_compare_screen_compare_needs_at_least_two_metrics_2bfe1fad": { + "en": "Compare needs at least two metrics with history", + "ar": "المقارنة تحتاج إلى مقياسين على الأقل لهما سجل" + }, + "l10n_compare_screen_not_enough_overlapping_days_between_these_44563110": { + "en": "Not enough overlapping days between these metrics in %1$s. Widen the range.", + "ar": "لا توجد أيام متداخلة كافية بين هذه المقاييس في %1$s. وسّع النطاق." + }, + "l10n_compare_screen_nothing_selected_yet_60968db6": { + "en": "Nothing selected yet.", + "ar": "لم يُختر شيء بعد." + }, + "l10n_compare_screen_p_n_overlapping_days_strengthword_p_8a8f56c7": { + "en": "%1$s overlapping days · %2$s %3$s correlation", + "ar": "%1$s يوم متداخل · ارتباط %2$s %3$s" + }, + "l10n_compare_screen_remove_title_ddb5361c": { + "en": "Remove %1$s", + "ar": "إزالة %1$s" + }, + "l10n_components_chunks_chunks_pulled_cec186cf": { + "en": "%1$s chunks pulled", + "ar": "تم سحب %1$s جزء" + }, + "l10n_components_decrease_accessibility_df5f1511": { + "en": "Decrease %1$s", + "ar": "إنقاص %1$s" + }, + "l10n_components_increase_accessibility_0949c0e9": { + "en": "Increase %1$s", + "ar": "زيادة %1$s" + }, + "l10n_connection_help_allow_nearby_devices_f7c74880": { + "en": "Allow Nearby devices", + "ar": "السماح بالأجهزة القريبة" + }, + "l10n_connection_help_charge_it_and_put_it_on_31b04814": { + "en": "Charge it and put it on", + "ar": "اشحنه وارتدِه" + }, + "l10n_connection_help_close_the_official_whoop_app_768d7d17": { + "en": "Close the official WHOOP app", + "ar": "أغلق تطبيق WHOOP الرسمي" + }, + "l10n_connection_help_try_connecting_now_41769900": { + "en": "Try connecting now", + "ar": "جرّب الاتصال الآن" + }, + "l10n_connection_help_turn_bluetooth_on_75868e97": { + "en": "Turn Bluetooth on", + "ar": "شغّل Bluetooth" + }, + "l10n_connection_help_whoop_5_mg_experimental_109076ae": { + "en": "WHOOP 5 / MG (experimental)", + "ar": "‏WHOOP 5 / MG (تجريبي)" + }, + "l10n_connection_help_won_t_connect_run_through_these_cfef82f3": { + "en": "Won\\'t connect? Run through these", + "ar": "لا يتصل؟ راجع هذه الخطوات" + }, + "l10n_connection_help_your_strap_is_connected_and_we_8ab98ac6": { + "en": "Your strap is connected and we\\'re trying an experimental handshake to bring up live ", + "ar": "سوارك متصل ونحن نجرّب مصافحة تجريبية لتشغيل البث المباشر " + }, + "l10n_coupled_screen_calibrating_calibrationnights_of_baselines_minnightsseed_nights_1a7e8f8e": { + "en": "Calibrating, %1$s of %2$s nights", + "ar": "جارٍ المعايرة، %1$s من %2$s ليلة" + }, + "l10n_coupled_screen_day_987b9ced": { + "en": "Day", + "ar": "اليوم" + }, + "l10n_coupled_screen_hoursminutes_asleepmin_slept_732a5b21": { + "en": "%1$s slept", + "ar": "%1$s نوم" + }, + "l10n_coupled_screen_hoursminutes_needmin_needed_7fc1eb15": { + "en": "%1$s needed", + "ar": "%1$s مطلوب" + }, + "l10n_coupled_screen_no_effort_yet_f622f99d": { + "en": "No effort yet", + "ar": "لا جهد بعد" + }, + "l10n_coupled_screen_no_sleep_tracked_last_night_cd0dd79e": { + "en": "No sleep tracked last night", + "ar": "لم يُسجَّل نوم الليلة الماضية" + }, + "l10n_coupled_screen_recovery_b668d988": { + "en": "RECOVERY", + "ar": "التعافي" + }, + "l10n_coupled_screen_sleep_performance_4611539f": { + "en": "SLEEP PERFORMANCE", + "ar": "أداء النوم" + }, + "l10n_data_sources_screen_acts_as_a_standard_bluetooth_heart_f8d13439": { + "en": "Acts as a standard Bluetooth heart-rate strap. Pair NOOP from your treadmill, bike or app to see your strap\\'s heart rate there.", + "ar": "يعمل كسوار نبض Bluetooth قياسي. اقترن بـ NOOP من جهاز المشي أو الدراجة أو التطبيق لرؤية نبض سوارك هناك." + }, + "l10n_data_sources_screen_auto_sync_health_connect_periodically_6f3f4d92": { + "en": "Auto-sync Health Connect periodically", + "ar": "مزامنة Health Connect تلقائيًا بشكل دوري" + }, + "l10n_data_sources_screen_auto_sync_periodically_5f3041e8": { + "en": "Auto-sync periodically", + "ar": "مزامنة تلقائية دورية" + }, + "l10n_data_sources_screen_broadcast_heart_rate_as_a_bluetooth_6a44fdb4": { + "en": "Broadcast heart rate as a Bluetooth sensor", + "ar": "بثّ معدل النبض كمستشعر Bluetooth" + }, + "l10n_data_sources_screen_broadcast_hr_from_this_phone_10e5605c": { + "en": "Broadcast HR from this phone", + "ar": "بثّ النبض من هذا الهاتف" + }, + "l10n_data_sources_screen_broadcast_hr_is_on_your_strap_0ad5368a": { + "en": "Broadcast HR is ON. This phone is advertising heart rate continuously, which keeps its Bluetooth radio active and drains the battery faster. Turn it off when you\\'re not using it with another device.", + "ar": "بثّ النبض مُفعَّل. يبثّ هذا الهاتف معدل النبض باستمرار، مما يبقي راديو Bluetooth نشطًا ويستنزف البطارية أسرع. أوقفه حين لا تستخدمه مع جهاز آخر." + }, + "l10n_data_sources_screen_cancel_77dfd213": { + "en": "Cancel", + "ar": "إلغاء" + }, + "l10n_data_sources_screen_data_sources_5e43d6bb": { + "en": "Data Sources", + "ar": "مصادر البيانات" + }, + "l10n_data_sources_screen_every_3560d90b": { + "en": "Every", + "ar": "كل" + }, + "l10n_data_sources_screen_import_apple_health_export_533fca27": { + "en": "Import Apple Health export…", + "ar": "استيراد ملف تصدير Apple Health…" + }, + "l10n_data_sources_screen_import_from_health_connect_35d55e21": { + "en": "Import from Health Connect", + "ar": "الاستيراد من Health Connect" + }, + "l10n_data_sources_screen_import_lifting_log_8fac7b68": { + "en": "Import lifting log…", + "ar": "استيراد سجل تمارين القوة…" + }, + "l10n_data_sources_screen_import_mi_band_export_e587801c": { + "en": "Import Mi Band export…", + "ar": "استيراد ملف تصدير Mi Band…" + }, + "l10n_data_sources_screen_import_nutrition_csv_2c748273": { + "en": "Import nutrition CSV…", + "ar": "استيراد تغذية CSV…" + }, + "l10n_data_sources_screen_import_wearable_export_0545c430": { + "en": "Import wearable export…", + "ar": "استيراد ملف تصدير جهاز ملبوس…" + }, + "l10n_data_sources_screen_import_whoop_export_zip_16f4176b": { + "en": "Import WHOOP export (.zip)", + "ar": "استيراد ملف تصدير WHOOP‏ (.zip)" + }, + "l10n_data_sources_screen_import_workout_file_a3a28e06": { + "en": "Import workout file…", + "ar": "استيراد ملف تمرين…" + }, + "l10n_data_sources_screen_imported_434eb26f": { + "en": "Imported", + "ar": "مستورد" + }, + "l10n_data_sources_screen_last_share_didn_t_finish_noop_0d6c27f0": { + "en": "Last share didn’t finish — NOOP will retry automatically.", + "ar": "لم تكتمل آخر مشاركة — سيعيد NOOP المحاولة تلقائيًا." + }, + "l10n_data_sources_screen_last_shared_6bf8389c": { + "en": "\"Last shared \"", + "ar": "\"آخر مشاركة \"" + }, + "l10n_data_sources_screen_last_sync_b793ffab": { + "en": "Last sync: ", + "ar": "آخر مزامنة: " + }, + "l10n_data_sources_screen_lifting_log_hevy_liftosaur_11df48df": { + "en": "Lifting log (Hevy / Liftosaur)", + "ar": "سجل تمارين القوة (Hevy / Liftosaur)" + }, + "l10n_data_sources_screen_nutrition_csv_1c1315d9": { + "en": "Nutrition (CSV)", + "ar": "التغذية (CSV)" + }, + "l10n_data_sources_screen_off_e3de5ab0": { + "en": "Off", + "ar": "مغلق" + }, + "l10n_data_sources_screen_oura_fitbit_garmin_export_7b21682f": { + "en": "Oura / Fitbit / Garmin export", + "ar": "تصدير Oura / Fitbit / Garmin" + }, + "l10n_data_sources_screen_re_pull_new_health_connect_data_3e9c3914": { + "en": "Re-pull new Health Connect data (e.g. Samsung Health → Health Connect) each ", + "ar": "إعادة سحب بيانات Health Connect الجديدة (مثل Samsung Health ← Health Connect) كل " + }, + "l10n_data_sources_screen_remove_apple_health_imported_data_5f878502": { + "en": "Remove Apple Health imported data?", + "ar": "إزالة البيانات المستوردة من Apple Health؟" + }, + "l10n_data_sources_screen_remove_e963907d": { + "en": "Remove", + "ar": "إزالة" + }, + "l10n_data_sources_screen_remove_imported_data_813e4695": { + "en": "Remove imported data", + "ar": "إزالة البيانات المستوردة" + }, + "l10n_data_sources_screen_share_back_to_health_connect_1d578f4a": { + "en": "Share back to Health Connect", + "ar": "المشاركة العكسية إلى Health Connect" + }, + "l10n_data_sources_screen_share_computed_metrics_back_to_health_c11f5d70": { + "en": "Share computed metrics back to Health Connect", + "ar": "مشاركة المقاييس المحتسبة عكسيًا إلى Health Connect" + }, + "l10n_data_sources_screen_sharing_paused_health_connect_permission_was_e8950315": { + "en": "Sharing paused — Health Connect permission was revoked. Tap to re-grant.", + "ar": "تم إيقاف المشاركة مؤقتًا — سُحب إذن Health Connect. اضغط لمنحه مجددًا." + }, + "l10n_data_sources_screen_this_permanently_deletes_everything_imported_from_f42e760e": { + "en": "This permanently deletes everything imported from Apple Health: heart rate, HRV, sleep, steps, workouts and more. Your live strap data is untouched. This can\\'t be undone.", + "ar": "يحذف هذا نهائيًا كل ما استُورد من Apple Health: معدل النبض وتقلب النبض والنوم والخطوات والتمارين وغيرها. لا تتأثر بيانات سوارك المباشرة. لا يمكن التراجع عن ذلك." + }, + "l10n_data_sources_screen_whoop_history_db101974": { + "en": "WHOOP History", + "ar": "سجل WHOOP" + }, + "l10n_data_sources_screen_whoop_strap_live_ble_217f7df6": { + "en": "WHOOP Strap (Live BLE)", + "ar": "سوار WHOOP (BLE مباشر)" + }, + "l10n_data_sources_screen_workout_file_gpx_tcx_fit_5469c068": { + "en": "Workout file (GPX / TCX / FIT)", + "ar": "ملف تمرين (GPX / TCX / FIT)" + }, + "l10n_data_sources_screen_write_the_metrics_noop_computes_from_439940c2": { + "en": "Write the metrics NOOP computes from your strap (resting HR, HRV, SpO₂, ", + "ar": "كتابة المقاييس التي يحتسبها NOOP من سوارك (نبض الراحة، وتقلب النبض، وSpO₂، " + }, + "l10n_devices_screen_add_a_device_f90866b8": { + "en": "Add a device", + "ar": "إضافة جهاز" + }, + "l10n_devices_screen_battery_4a9be042": { + "en": "Battery", + "ar": "البطارية" + }, + "l10n_devices_screen_battery_clamped_2494c8c9": { + "en": "Battery %1$s%%", + "ar": "البطارية %1$s%%" + }, + "l10n_devices_screen_battery_info_probe_592_re_1dbd4c0f": { + "en": "Battery-info probe (#592 RE)…", + "ar": "فحص معلومات البطارية (#592 RE)…" + }, + "l10n_devices_screen_battery_info_probe_result_592_b97c0bb8": { + "en": "Battery-info probe result (#592)", + "ar": "نتيجة فحص البطارية (#592)" + }, + "l10n_devices_screen_battery_probe_explainer_2858bb6a": { + "en": "Two independent protocol tables disagree on the extended-battery opcode (98 vs 87). This sends the curated read-only 98 and dumps the strap\\'s full raw reply to the strap log. A battery-style payload confirms 98 on your firmware; a short stub means it stays ambiguous. Nothing is written to the strap. Please export and share the strap log afterwards.", + "ar": "تختلف جدولان مستقلان للبروتوكول حول رمز البطارية الممتد (98 مقابل 87). يرسل هذا الأمر 98 للقراءة فقط ويكتب رد السوار الخام الكامل في سجل السوار. الرد بصيغة بطارية يؤكد 98 على برنامجك الثابت؛ والرد المختصر يعني بقاء الأمر ملتبسًا. لا يُكتب شيء على السوار. يُرجى تصدير سجل السوار ومشاركته بعد ذلك." + }, + "l10n_devices_screen_body_location_probe_690_re_7def8c39": { + "en": "Body-location probe (#690 RE)…", + "ar": "فحص موضع الجسم (#690 RE)…" + }, + "l10n_devices_screen_body_location_probe_explainer_a9363239": { + "en": "Sends the read-only body-location command (0x54) and dumps the strap\\'s full raw reply to the strap log, decoding the record (revision / location / confidence / status) on WHOOP 4.0. Nothing is written to the strap, and it never changes wear detection or scoring.", + "ar": "يرسل أمر موضع الجسم للقراءة فقط (0x54) ويكتب رد السوار الخام الكامل في السجل، مع فكّ ترميز السجل (المراجعة / الموضع / الثقة / الحالة) على WHOOP 4.0. لا يُكتب شيء على السوار، ولا يغيّر أبدًا كشف الارتداء أو الدرجات." + }, + "l10n_devices_screen_body_location_probe_result_690_60c5ee79": { + "en": "Body-location probe result (#690)", + "ar": "نتيجة فحص موضع الجسم (#690)" + }, + "l10n_devices_screen_cancel_77dfd213": { + "en": "Cancel", + "ar": "إلغاء" + }, + "l10n_devices_screen_close_bbfa773e": { + "en": "Close", + "ar": "إغلاق" + }, + "l10n_devices_screen_copy_af74f7c5": { + "en": "Copy", + "ar": "نسخ" + }, + "l10n_devices_screen_delete_all_of_this_device_s_754cde90": { + "en": "Delete all of this device\\'s data?", + "ar": "حذف كل بيانات هذا الجهاز؟" + }, + "l10n_devices_screen_delete_this_device_s_data_3cae7a2a": { + "en": "Delete this device\\'s data…", + "ar": "حذف بيانات هذا الجهاز…" + }, + "l10n_devices_screen_device_actions_for_displayname_device_160eb3de": { + "en": "Device actions for %1$s", + "ar": "إجراءات الجهاز لـ %1$s" + }, + "l10n_devices_screen_device_config_probe_explainer_dd23169f": { + "en": "Asks the strap for config VALUES: GET_DEVICE_CONFIG_VALUE (0x79) and GET_FF_VALUE (0x80), one key per round-trip. Both commands may simply not exist in this firmware — finding that out is the point. Read-only — no value is written, and the SET commands are never sent from this probe. The result is shown here and written to the strap log.", + "ar": "يطلب من السوار قيم الإعدادات: GET_DEVICE_CONFIG_VALUE (0x79) وGET_FF_VALUE (0x80)، مفتاح واحد لكل دورة. قد لا يكون الأمران موجودين أصلًا في هذا البرنامج الثابت — ومعرفة ذلك هي الهدف. للقراءة فقط — لا تُكتب أي قيمة، ولا تُرسل أوامر الضبط أبدًا من هذا الفحص. تُعرض النتيجة هنا وتُكتب في سجل السوار." + }, + "l10n_devices_screen_device_config_read_probe_103_re_837f46de": { + "en": "Device-config read probe (#103 RE)…", + "ar": "فحص قراءة إعدادات الجهاز (#103 RE)…" + }, + "l10n_devices_screen_device_config_read_probe_result_103_67d02ec9": { + "en": "Device-config read probe result (#103)", + "ar": "نتيجة فحص قراءة إعدادات الجهاز (#103)" + }, + "l10n_devices_screen_device_name_79d7a157": { + "en": "Device name", + "ar": "اسم الجهاز" + }, + "l10n_devices_screen_devices_df485c87": { + "en": "Devices", + "ar": "الأجهزة" + }, + "l10n_devices_screen_disconnect_ed28e068": { + "en": "Disconnect", + "ar": "قطع الاتصال" + }, + "l10n_devices_screen_feature_flag_probe_761_re_21241d68": { + "en": "Feature-flag probe (#761 RE)…", + "ar": "فحص أعلام الميزات (#761 RE)…" + }, + "l10n_devices_screen_feature_flag_probe_explainer_58eec30f": { + "en": "Asks the strap to list the feature-flag NAMES its own firmware knows: START_FF_KEY_EXCHANGE (0x75) then SEND_NEXT_FF (0x76) until the strap says it\\'s done. Read-only — no flag value is written, and the SET commands are never sent from this probe. The list is shown here and written to the strap log.", + "ar": "يطلب من السوار سرد أسماء أعلام الميزات التي يعرفها برنامجه الثابت: START_FF_KEY_EXCHANGE (0x75) ثم SEND_NEXT_FF (0x76) حتى يعلن السوار الانتهاء. للقراءة فقط — لا تُكتب أي قيمة علم، ولا تُرسل أوامر الضبط أبدًا من هذا الفحص. تُعرض القائمة هنا وتُكتب في سجل السوار." + }, + "l10n_devices_screen_feature_flag_probe_result_761_c50ef4d4": { + "en": "Feature-flag probe result (#761)", + "ar": "نتيجة فحص أعلام الميزات (#761)" + }, + "l10n_devices_screen_forget_device_926e503d": { + "en": "Forget device", + "ar": "نسيان الجهاز" + }, + "l10n_devices_screen_forget_device_d6eb6209": { + "en": "Forget device…", + "ar": "نسيان الجهاز…" + }, + "l10n_devices_screen_forget_this_device_8dbbc3f3": { + "en": "Forget this device?", + "ar": "نسيان هذا الجهاز؟" + }, + "l10n_devices_screen_getting_your_devices_ready_bd391949": { + "en": "Getting your devices ready", + "ar": "جارٍ تجهيز أجهزتك" + }, + "l10n_devices_screen_give_device_brand_device_model_a_7a15585c": { + "en": "Give %1$s %2$s a name you\\'ll recognise.", + "ar": "امنح %1$s %2$s اسمًا تتعرف عليه." + }, + "l10n_devices_screen_leave_none_active_b5c858cb": { + "en": "Leave none active", + "ar": "عدم تفعيل أي جهاز" + }, + "l10n_devices_screen_make_active_75690bb8": { + "en": "Make active", + "ar": "تعيينه نشطًا" + }, + "l10n_devices_screen_make_this_your_active_strap_fea6bebd": { + "en": "Make this your active strap?", + "ar": "تعيين هذا سوارك النشط؟" + }, + "l10n_devices_screen_name_709a2322": { + "en": "Name", + "ar": "الاسم" + }, + "l10n_devices_screen_noop_removes_this_device_503aff9e": { + "en": "NOOP removes this device from your list and deletes its recorded data here. You can re-pair the strap to pull its recent history back.", + "ar": "يزيل NOOP هذا الجهاز من قائمتك ويحذف بياناته المسجّلة هنا. يمكنك إعادة اقتران السوار لسحب سجله الأخير مرة أخرى." + }, + "l10n_devices_screen_pack_voltage_9af3c3ff": { + "en": "%1$.2f V", + "ar": "%1$.2f فولت" + }, + "l10n_devices_screen_paired_locally_noop_owns_this_ring_30c16190": { + "en": "Paired locally. NOOP owns this ring while it holds the key. If you reset it again or set it up in the Oura app, NOOP no longer owns it and you would re-add it to take it over.", + "ar": "مقترن محليًا. يملك NOOP هذا الخاتم ما دام يحتفظ بالمفتاح. وإذا أعدت ضبطه أو أعددته في تطبيق Oura، فلن يعود NOOP مالكًا له وستحتاج إلى إعادة إضافته للاستحواذ عليه." + }, + "l10n_devices_screen_pick_a_new_active_strap_edd73542": { + "en": "Pick a new active strap", + "ar": "اختر سوارًا نشطًا جديدًا" + }, + "l10n_devices_screen_reboot_probe_4_0_re_828b3916": { + "en": "Reboot probe (4.0 RE)…", + "ar": "فحص إعادة التشغيل (4.0 RE)…" + }, + "l10n_devices_screen_reconnect_6988b16a": { + "en": "Reconnect", + "ar": "إعادة الاتصال" + }, + "l10n_devices_screen_remove_e963907d": { + "en": "Remove", + "ar": "إزالة" + }, + "l10n_devices_screen_remove_this_device_dd9dbda9": { + "en": "Remove this device?", + "ar": "إزالة هذا الجهاز؟" + }, + "l10n_devices_screen_rename_d3f4cb89": { + "en": "Rename", + "ar": "إعادة تسمية" + }, + "l10n_devices_screen_rename_device_ee233604": { + "en": "Rename device", + "ar": "إعادة تسمية الجهاز" + }, + "l10n_devices_screen_restart_strap_c976cd6c": { + "en": "Restart strap…", + "ar": "إعادة تشغيل السوار…" + }, + "l10n_devices_screen_restart_this_strap_50fc481b": { + "en": "Restart this strap?", + "ar": "إعادة تشغيل هذا السوار؟" + }, + "l10n_devices_screen_save_efc007a3": { + "en": "Save", + "ar": "حفظ" + }, + "l10n_devices_screen_send_probe_read_only_36b318bc": { + "en": "Send probe (read-only)", + "ar": "إرسال الفحص (للقراءة فقط)" + }, + "l10n_devices_screen_stop_sync_4a1f2b6e": { + "en": "Stop sync", + "ar": "إيقاف المزامنة" + }, + "l10n_devices_screen_the_whoop_4_0_reboot_frame_690a8ff2": { + "en": "The WHOOP 4.0 reboot frame isn\\'t confirmed — a normal Restart is ignored (#235). ", + "ar": "إطار إعادة تشغيل WHOOP 4.0 غير مؤكد — وإعادة التشغيل العادية تُتجاهل (#235). " + }, + "l10n_devices_screen_waiting_for_the_straps_reply_5a06e7ac": { + "en": "Waiting for the strap\\'s reply…", + "ar": "في انتظار رد السوار…" + }, + "l10n_devices_screen_whoop_4_0_reboot_probe_b51fb50f": { + "en": "WHOOP 4.0 reboot probe", + "ar": "فحص إعادة تشغيل WHOOP 4.0" + }, + "l10n_devices_screen_whoop_is_noop_s_primary_fully_1c9e67fd": { + "en": "WHOOP is NOOP\\'s primary, fully-supported band. Other heart-rate straps are an early, ", + "ar": "‏WHOOP هو السوار الأساسي المدعوم بالكامل في NOOP. أساور النبض الأخرى في مرحلة مبكرة، " + }, + "l10n_devices_screen_you_removed_your_active_strap_choose_2ac91d48": { + "en": "You removed your active strap. Choose which paired band provides your live data, or leave none active and pair one later.", + "ar": "لقد أزلت سوارك النشط. اختر السوار المقترن الذي يوفّر بياناتك المباشرة، أو اترك الجميع غير نشط واقترن بواحد لاحقًا." + }, + "l10n_fused_record_screen_done_e9b450d1": { + "en": "Done", + "ar": "تم" + }, + "l10n_fused_record_screen_fused_on_devicenoun_nothing_leaves_it_8a1e6a3d": { + "en": "Fused on %1$s. Nothing leaves it: no account, no cloud.", + "ar": "مدمج على %1$s. لا شيء يغادره: بلا حساب وبلا سحابة." + }, + "l10n_fused_record_screen_noop_picks_the_best_sourced_number_5e48b86a": { + "en": "NOOP picks the best-sourced number and shows you where each came from. It\\'s for wellness and curiosity. It doesn\\'t diagnose or replace medical advice.", + "ar": "يختار NOOP الرقم من أفضل مصدر ويعرض لك مصدر كل رقم. الغرض منه العافية والفضول. لا يشخّص ولا يحل محل الاستشارة الطبية." + }, + "l10n_fused_record_screen_noop_shows_the_winner_source_displayname_4a3892cc": { + "en": "NOOP shows the %1$s reading because it %2$s for this metric: a higher-trust source here, not a verdict that the others are wrong.", + "ar": "يعرض NOOP قراءة %1$s لأنها %2$s لهذا المقياس: مصدر أعلى ثقة هنا، لا حكمًا بأن البقية خاطئة." + }, + "l10n_fused_record_screen_nothing_to_fuse_yet_30789c4e": { + "en": "Nothing to fuse yet", + "ar": "لا شيء لدمجه بعد" + }, + "l10n_fused_record_screen_other_source_displayname_agrees_fusionformat_value_78e29c2a": { + "en": "%1$s agrees: %2$s", + "ar": "%1$s يتفق: %2$s" + }, + "l10n_fused_record_screen_your_bands_report_different_numbers_here_03d20d81": { + "en": "Your bands report different numbers. Here\\'s every source, and the one NOOP is using.", + "ar": "أساورك تسجّل أرقامًا مختلفة. إليك كل المصادر، والمصدر الذي يستخدمه NOOP." + }, + "l10n_fused_record_screen_your_data_fused_a740fd4a": { + "en": "Your Data, Fused", + "ar": "بياناتك، مدمجة" + }, + "l10n_health_screen_5_yr_a_fitness_comparison_not_418aa11d": { + "en": "± 5 yr · a fitness comparison, not a biological age", + "ar": "± 5 سنوات · مقارنة لياقة، لا عمر بيولوجي" + }, + "l10n_health_screen_a_wellness_estimate_from_your_habits_d00f36de": { + "en": "A wellness estimate from your habits, not a clinical biological age.", + "ar": "تقدير للعافية من عاداتك، لا عمر بيولوجي سريري." + }, + "l10n_health_screen_active_energy_2d3288f9": { + "en": "Active Energy", + "ar": "الطاقة النشطة" + }, + "l10n_health_screen_add_your_waist_for_a_more_accurate_vo_max_829f5e1e": { + "en": "Add your waist for a more accurate VO₂max", + "ar": "أضف محيط خصرك لتقدير VO₂max أدق" + }, + "l10n_health_screen_as_of_latest_first_726f20bb": { + "en": "as of %1$s", + "ar": "حتى %1$s" + }, + "l10n_health_screen_as_of_one_first_2b409612": { + "en": "as of %1$s", + "ar": "حتى %1$s" + }, + "l10n_health_screen_baselines_learned_on_device_over_14_c107f375": { + "en": "Baselines learned on-device over 14 days. Bars read each signal against a ", + "ar": "خطوط الأساس مُتعلَّمة على الجهاز خلال 14 يومًا. تقرأ الأشرطة كل إشارة مقابل " + }, + "l10n_health_screen_blood_o_9bf5ed9b": { + "en": "Blood O₂", + "ar": "أكسجين الدم" + }, + "l10n_health_screen_blood_oxygen_a8ad9ff5": { + "en": "Blood Oxygen", + "ar": "أكسجين الدم" + }, + "l10n_health_screen_cycle_phase_body_clock_and_illness_59e2d9a4": { + "en": "Cycle phase, body-clock and illness heads-up are approximations computed on your device from ", + "ar": "طور الدورة والساعة البيولوجية والإنذار بالمرض تقديرات محتسبة على جهازك من " + }, + "l10n_health_screen_date_eb9a4bc1": { + "en": "Date", + "ar": "التاريخ" + }, + "l10n_health_screen_drives_your_fitness_age_9d0d1219": { + "en": "Drives your Fitness Age", + "ar": "يحدّد عمرك اللياقي" + }, + "l10n_health_screen_effort_8c974bc6": { + "en": "Effort", + "ar": "الجهد" + }, + "l10n_health_screen_fitness_age_12383b4a": { + "en": "Fitness Age", + "ar": "العمر اللياقي" + }, + "l10n_health_screen_fix_in_settings_d7472915": { + "en": "Fix in Settings", + "ar": "إصلاح في الإعدادات" + }, + "l10n_health_screen_health_monitor_c4abc3fc": { + "en": "Health Monitor", + "ar": "مراقب الصحة" + }, + "l10n_health_screen_heart_rate_dde6e8f7": { + "en": "Heart Rate", + "ar": "معدل النبض" + }, + "l10n_health_screen_heart_rate_variability_20f0069e": { + "en": "Heart Rate Variability", + "ar": "تقلب معدل النبض" + }, + "l10n_health_screen_helping_most_best_label_edee8773": { + "en": "Helping most: %1$s", + "ar": "الأكثر مساعدة: %1$s" + }, + "l10n_health_screen_history_synced_8339779d": { + "en": "History synced", + "ar": "تمت مزامنة السجل" + }, + "l10n_health_screen_holding_you_back_worst_label_863a1809": { + "en": "Holding you back: %1$s", + "ar": "ما يعيقك: %1$s" + }, + "l10n_health_screen_how_accurate_is_this_dae653a8": { + "en": "How accurate is this?", + "ar": "ما مدى دقة هذا؟" + }, + "l10n_health_screen_how_accurate_is_this_fitness_age_935c9a6d": { + "en": "How accurate is this Fitness Age?", + "ar": "ما مدى دقة العمر اللياقي هذا؟" + }, + "l10n_health_screen_it_compares_your_resting_heart_rate_e83e00f5": { + "en": "It compares your resting heart rate and recent activity against people your age. ", + "ar": "يقارن نبضك في الراحة ونشاطك الأخير بأشخاص في عمرك. " + }, + "l10n_health_screen_lab_book_f966c140": { + "en": "Lab Book", + "ar": "دفتر المختبر" + }, + "l10n_health_screen_loading_33ce4174": { + "en": "Loading…", + "ar": "جارٍ التحميل…" + }, + "l10n_health_screen_longer_ranges_unlock_as_more_history_d7da5fee": { + "en": "Longer ranges unlock as more history builds.", + "ar": "تُفتح النطاقات الأطول كلما تراكم السجل." + }, + "l10n_health_screen_no_biometrics_yet_7c594a6c": { + "en": "No biometrics yet", + "ar": "لا قياسات حيوية بعد" + }, + "l10n_health_screen_no_blood_oxygen_percentage_from_a_1d3d383e": { + "en": "No blood oxygen percentage from a WHOOP 4.0", + "ar": "لا نسبة أكسجين دم من WHOOP 4.0" + }, + "l10n_health_screen_no_measured_temperature_for_these_nights_showing_the_differe_69d4efae": { + "en": "No measured temperature for these nights — showing the difference from your baseline. A sync re-scores recent nights and fills it in.", + "ar": "لا حرارة مقيسة لهذه الليالي — يُعرض الفارق عن خط أساسك. المزامنة تعيد احتساب الليالي الأخيرة وتملأه." + }, + "l10n_health_screen_no_spo_import_or_health_value_408f8c55": { + "en": "No SpO₂ import or Health value", + "ar": "لا استيراد SpO₂ ولا قيمة من Health" + }, + "l10n_health_screen_no_strap_connected_fb37b99e": { + "en": "No strap connected", + "ar": "لا سوار متصل" + }, + "l10n_health_screen_not_enough_history_in_this_range_2da72f80": { + "en": "Not enough history in this range", + "ar": "لا يوجد سجل كافٍ في هذا النطاق" + }, + "l10n_health_screen_not_enough_history_yet_0e2f93b6": { + "en": "Not enough history yet", + "ar": "لا يوجد سجل كافٍ بعد" + }, + "l10n_health_screen_one_reading_so_far_your_trend_eaad57f2": { + "en": "One reading so far — your trend chart fills in here once a second ", + "ar": "قراءة واحدة حتى الآن — يمتلئ رسم اتجاهك هنا بمجرد وجود قراءة ثانية " + }, + "l10n_health_screen_only_nights_with_a_measured_temperature_are_shown_the_others_b6f7c45b": { + "en": "Only nights with a measured temperature are shown; the others recorded a baseline difference only.", + "ar": "تُعرض فقط الليالي ذات الحرارة المقيسة؛ أما البقية فسجّلت فارقًا عن خط الأساس فحسب." + }, + "l10n_health_screen_out_of_100_da0953a8": { + "en": "out of 100", + "ar": "من 100" + }, + "l10n_health_screen_raw_counts_only_needs_an_import_d0e33552": { + "en": "Raw counts only — needs an import", + "ar": "أعداد خام فقط — يحتاج إلى استيراد" + }, + "l10n_health_screen_raw_spo_ccfe80c1": { + "en": "Raw SpO₂", + "ar": "‏SpO₂ الخام" + }, + "l10n_health_screen_recovery_ea924f72": { + "en": "Recovery", + "ar": "التعافي" + }, + "l10n_health_screen_refresh_fitness_age_now_85fc516f": { + "en": "Refresh Fitness Age now", + "ar": "تحديث العمر اللياقي الآن" + }, + "l10n_health_screen_resp_rate_1c48dbd8": { + "en": "Resp Rate", + "ar": "معدل التنفس" + }, + "l10n_health_screen_respiratory_1cd8c175": { + "en": "Respiratory", + "ar": "التنفسي" + }, + "l10n_health_screen_respiratory_rate_3fbb532f": { + "en": "Respiratory Rate", + "ar": "معدل التنفس" + }, + "l10n_health_screen_rest_b79e5f48": { + "en": "Rest", + "ar": "الراحة" + }, + "l10n_health_screen_resting_heart_rate_9700f4d8": { + "en": "Resting Heart Rate", + "ar": "معدل النبض في الراحة" + }, + "l10n_health_screen_resting_hr_26677094": { + "en": "Resting HR", + "ar": "نبض الراحة" + }, + "l10n_health_screen_sharpens_your_vo_max_c9d52991": { + "en": "Sharpens your VO₂max", + "ar": "يحسّن دقة VO₂max لديك" + }, + "l10n_health_screen_skin_temp_a4affc5a": { + "en": "Skin Temp", + "ar": "حرارة الجلد" + }, + "l10n_health_screen_skin_temperature_f59127f6": { + "en": "Skin Temperature", + "ar": "حرارة الجلد" + }, + "l10n_health_screen_sleep_3cac34e6": { + "en": "Sleep", + "ar": "النوم" + }, + "l10n_health_screen_source_6da13add": { + "en": "Source", + "ar": "المصدر" + }, + "l10n_health_screen_spo_respiratory_rate_and_skin_temperature_0ae0ad8f": { + "en": "SpO₂, respiratory rate and skin temperature are sleep-window ", + "ar": "‏SpO₂ ومعدل التنفس وحرارة الجلد قياسات نافذة النوم " + }, + "l10n_health_screen_steps_cdde4f20": { + "en": "Steps", + "ar": "الخطوات" + }, + "l10n_health_screen_the_blood_oxygen_estimate_is_turned_4c403ab2": { + "en": "The blood oxygen estimate is turned off", + "ar": "تقدير أكسجين الدم مُعطَّل" + }, + "l10n_health_screen_this_vital_needs_at_least_two_0e41b8b0": { + "en": "This vital needs at least two historical readings before NOOP can chart it.", + "ar": "يحتاج هذا المؤشر إلى قراءتين تاريخيتين على الأقل قبل أن يتمكن NOOP من رسمه." + }, + "l10n_health_screen_value_8dce170d": { + "en": "Value", + "ar": "القيمة" + }, + "l10n_health_screen_vital_signs_e7d9e1b1": { + "en": "Vital Signs", + "ar": "العلامات الحيوية" + }, + "l10n_health_screen_vitality_be320b06": { + "en": "Vitality", + "ar": "الحيوية" + }, + "l10n_health_screen_weight_height_and_waist_add_a_fd2699f5": { + "en": "Weight, height and waist add a VO₂max estimate. They don\\'t change the Fitness Age itself.", + "ar": "الوزن والطول ومحيط الخصر تضيف تقديرًا لـ VO₂max. وهي لا تغيّر العمر اللياقي نفسه." + }, + "l10n_health_screen_your_data_fused_a740fd4a": { + "en": "Your Data, Fused", + "ar": "بياناتك، مدمجة" + }, + "l10n_health_screen_your_strap_banks_the_raw_optical_b52a0f80": { + "en": "Your strap banks the raw optical channels, but NOOP does not turn them into a blood oxygen percentage, so this stays empty however long you wear it. Import a WHOOP export in Data Sources to fill it from your account history.", + "ar": "يخزّن سوارك القنوات الضوئية الخام، لكن NOOP لا يحوّلها إلى نسبة أكسجين في الدم، لذا يبقى هذا فارغًا مهما طال ارتداؤك له. استورد ملف تصدير WHOOP في «مصادر البيانات» لملئه من سجل حسابك." + }, + "l10n_health_screen_your_strap_reports_a_blood_oxygen_349fe34a": { + "en": "Your strap reports a blood oxygen estimate, but it is unverified and off by default. Turn on the strap estimate in Settings to see it, or import a WHOOP export in Data Sources for the calibrated value.", + "ar": "يسجّل سوارك تقديرًا لأكسجين الدم، لكنه غير متحقق منه ومعطّل افتراضيًا. فعّل تقدير السوار في الإعدادات لرؤيته، أو استورد ملف تصدير WHOOP في «مصادر البيانات» للقيمة المعايرة." + }, + "l10n_how_noop_works_screen_close_bbfa773e": { + "en": "Close", + "ar": "إغلاق" + }, + "l10n_how_noop_works_screen_got_it_5b8027fa": { + "en": "Got it", + "ar": "فهمت" + }, + "l10n_how_noop_works_screen_how_noop_works_3396b27a": { + "en": "How NOOP works", + "ar": "كيف يعمل NOOP" + }, + "l10n_how_noop_works_screen_how_your_scores_are_computed_1b2d6c30": { + "en": "How your scores are computed", + "ar": "كيف تُحتسب درجاتك" + }, + "l10n_how_noop_works_screen_how_your_scores_work_21a0e2be": { + "en": "How your scores work", + "ar": "كيف تعمل درجاتك" + }, + "l10n_how_noop_works_screen_how_your_sleep_is_sorted_6a8e82e0": { + "en": "How your sleep is sorted", + "ar": "كيف يُصنَّف نومك" + }, + "l10n_how_noop_works_screen_noop_never_makes_up_a_number_da29aca5": { + "en": "NOOP never makes up a number. When it can\\'t compute one honestly it tells you what\\'s missing and what to do, rather than showing a fake value.", + "ar": "لا يختلق NOOP رقمًا أبدًا. وحين يتعذّر احتسابه بصدق، يخبرك بما ينقص وما ينبغي فعله، بدلًا من عرض قيمة زائفة." + }, + "l10n_how_noop_works_screen_noop_never_shows_you_a_number_d1db9958": { + "en": "NOOP never shows you a number it had to make up. If a score isn\\'t ready, ", + "ar": "لا يعرض NOOP أبدًا رقمًا اضطر لاختلاقه. وإذا لم تكن الدرجة جاهزة، " + }, + "l10n_how_noop_works_screen_sleep_scores_recording_where_your_numbers_1b3e981f": { + "en": "Sleep · scores · recording · where your numbers come from", + "ar": "النوم · الدرجات · التسجيل · من أين تأتي أرقامك" + }, + "l10n_how_noop_works_screen_what_recording_means_b896c422": { + "en": "What \"recording\" means", + "ar": "ماذا يعني «التسجيل»" + }, + "l10n_how_noop_works_screen_where_your_numbers_come_from_e169963a": { + "en": "Where your numbers come from", + "ar": "من أين تأتي أرقامك" + }, + "l10n_hrv_snapshot_screen_a_60_second_snapshot_of_your_35f03f7c": { + "en": "A 60-second snapshot of your beat-to-beat (R-R) intervals from the strap, cleaned ", + "ar": "لقطة مدتها 60 ثانية لفترات ما بين النبضات (R-R) من السوار، بعد تنظيفها " + }, + "l10n_hrv_snapshot_screen_an_hrv_reading_needs_the_live_11b70bff": { + "en": "An HRV reading needs the live R-R stream. Open the Live screen and connect your strap, then come back.", + "ar": "تحتاج قراءة تقلب النبض إلى بث R-R المباشر. افتح شاشة «مباشر» ووصّل سوارك، ثم عد." + }, + "l10n_hrv_snapshot_screen_beats_12aafda0": { + "en": "Beats", + "ar": "النبضات" + }, + "l10n_hrv_snapshot_screen_close_hrv_reading_a29a99a1": { + "en": "Close HRV reading", + "ar": "إغلاق قراءة تقلب النبض" + }, + "l10n_hrv_snapshot_screen_hrv_reading_a2cf71f3": { + "en": "HRV Reading", + "ar": "قراءة تقلب النبض" + }, + "l10n_hrv_snapshot_screen_mean_hr_6c9272dd": { + "en": "Mean HR", + "ar": "متوسط النبض" + }, + "l10n_hrv_snapshot_screen_not_enough_clean_beats_sit_still_0893e6c2": { + "en": "Not enough clean beats - sit still and try again. %1$s of ", + "ar": "لا توجد نبضات نظيفة كافية — اجلس دون حركة وحاول مجددًا. %1$s من " + }, + "l10n_hydration_screen_a_simple_goal_that_adjusts_to_723dc08a": { + "en": "A simple goal that adjusts to your effort. General wellness guidance, not medical advice.", + "ar": "هدف بسيط يتكيّف مع مجهودك. إرشاد عام للعافية، لا نصيحة طبية." + }, + "l10n_hydration_screen_bottle_3b8ac6c7": { + "en": "Bottle", + "ar": "زجاجة" + }, + "l10n_hydration_screen_cancel_77dfd213": { + "en": "Cancel", + "ar": "إلغاء" + }, + "l10n_hydration_screen_clear_today_1be870ea": { + "en": "Clear today", + "ar": "مسح اليوم" + }, + "l10n_hydration_screen_cup_04b95071": { + "en": "Cup", + "ar": "كوب" + }, + "l10n_hydration_screen_custom_amount_f7219236": { + "en": "Custom amount", + "ar": "كمية مخصصة" + }, + "l10n_hydration_screen_enter_any_amount_from_1_to_a85a639e": { + "en": "Enter any amount from 1 to %1$s ml.", + "ar": "أدخل أي كمية من 1 إلى %1$s مل." + }, + "l10n_hydration_screen_from_health_connect_4ad037a3": { + "en": "From Health Connect", + "ar": "من Health Connect" + }, + "l10n_hydration_screen_hydration_bdfb040f": { + "en": "Hydration", + "ar": "الترطيب" + }, + "l10n_hydration_screen_kotlin_math_min_100_fraction_100_416d2889": { + "en": "%1$s percent of today\\'s goal", + "ar": "%1$s بالمئة من هدف اليوم" + }, + "l10n_hydration_screen_kotlin_math_min_100_fraction_100_72f2dfde": { + "en": "%1$s%% of today\\'s goal", + "ar": "%1$s%% من هدف اليوم" + }, + "l10n_hydration_screen_log_8bf95ea3": { + "en": "Log", + "ar": "تسجيل" + }, + "l10n_hydration_screen_logged_today_0071a46c": { + "en": "Logged today", + "ar": "سُجِّل اليوم" + }, + "l10n_hydration_screen_millilitres_ml_0fb0d2a4": { + "en": "Millilitres (ml)", + "ar": "مليلتر (مل)" + }, + "l10n_hydration_screen_no_drinks_logged_yet_tap_sip_cc0d2f72": { + "en": "No drinks logged yet. Tap Sip, Cup or Bottle to start.", + "ar": "لم تُسجَّل مشروبات بعد. اضغط «رشفة» أو «كوب» أو «زجاجة» للبدء." + }, + "l10n_hydration_screen_no_history_yet_933f417e": { + "en": "No history yet.", + "ar": "لا سجل بعد." + }, + "l10n_hydration_screen_sip_78cca3e8": { + "en": "Sip", + "ar": "رشفة" + }, + "l10n_hydration_screen_sip_hydrationgoal_sip_ml_ml_cup_287647f5": { + "en": "Sip %1$s ml · Cup %2$s ml · Bottle %3$s ml", + "ar": "رشفة %1$s مل · كوب %2$s مل · زجاجة %3$s مل" + }, + "l10n_hydration_screen_totalml_toint_ml_522b262a": { + "en": "%1$s ml", + "ar": "%1$s مل" + }, + "l10n_hydration_screen_undo_last_last_ml_330befc5": { + "en": "Undo last (%1$s ml)", + "ar": "تراجع عن الأخير (%1$s مل)" + }, + "l10n_insights_hub_screen_dose_here_is_timing_later_in_1b1e9326": { + "en": "“Dose” here is timing (later in the day = stronger), not milligrams.", + "ar": "«الجرعة» هنا تعني التوقيت (كلما تأخر اليوم كان الأثر أقوى)، لا المليغرامات." + }, + "l10n_insights_hub_screen_everything_here_is_a_pattern_in_ed2162a6": { + "en": "Everything here is a pattern in your own logged days: an association with an ", + "ar": "كل ما هنا نمط في أيامك المسجّلة أنت: ارتباط مع " + }, + "l10n_insights_hub_screen_insights_b4510362": { + "en": "Insights", + "ar": "الرؤى" + }, + "l10n_insights_hub_screen_log_alcohol_or_late_caffeine_with_dec9dadf": { + "en": "Log alcohol or late caffeine with an amount and NOOP fits a personal dose curve: ", + "ar": "سجّل الكحول أو الكافيين المتأخر مع الكمية، ويضبط NOOP منحنى جرعة شخصيًا: " + }, + "l10n_insights_hub_screen_not_enough_overlap_between_your_journal_0ebdd7a2": { + "en": "Not enough overlap between your journal answers and ", + "ar": "لا يوجد تداخل كافٍ بين إجاباتك في اليوميات و" + }, + "l10n_insights_hub_screen_per_extra_card_unitnoun_a92a5e91": { + "en": "Per extra %1$s", + "ar": "لكل %1$s إضافي" + }, + "l10n_insights_hub_screen_reading_your_journal_and_outcomes_4a59af69": { + "en": "Reading your journal and outcomes…", + "ar": "جارٍ قراءة يومياتك ونتائجك…" + }, + "l10n_insights_hub_screen_tomorrow_s_card_outcomename_ad70b7f9": { + "en": "Tomorrow’s %1$s", + "ar": "%1$s غدًا" + }, + "l10n_insights_hub_screen_with_564f8c6e": { + "en": "With", + "ar": "مع" + }, + "l10n_insights_hub_screen_without_cb735356": { + "en": "Without", + "ar": "بدون" + }, + "l10n_insights_screen_baseline_e6ab7982": { + "en": "Baseline", + "ar": "خط الأساس" + }, + "l10n_insights_screen_bounce_back_be2d66a4": { + "en": "Bounce back", + "ar": "سرعة التعافي" + }, + "l10n_insights_screen_change_64fbd995": { + "en": "Change", + "ar": "التغيّر" + }, + "l10n_insights_screen_charge_d4e1aee4": { + "en": "Charge", + "ar": "الشحن" + }, + "l10n_insights_screen_compliance_68f0ae49": { + "en": "Compliance", + "ar": "الالتزام" + }, + "l10n_insights_screen_end_a2bb9d34": { + "en": "End", + "ar": "إنهاء" + }, + "l10n_insights_screen_end_experiment_0ed6d57f": { + "en": "End experiment", + "ar": "إنهاء التجربة" + }, + "l10n_insights_screen_experiment_behaviour_selection_bcb29b58": { + "en": "Experiment behaviour: %1$s", + "ar": "سلوك التجربة: %1$s" + }, + "l10n_insights_screen_experiment_progress_snapshot_dayselapsed_of_snapshot_ff62b228": { + "en": "Experiment progress %1$s of %2$s days", + "ar": "تقدّم التجربة %1$s من %2$s يوم" + }, + "l10n_insights_screen_insights_b4510362": { + "en": "Insights", + "ar": "الرؤى" + }, + "l10n_insights_screen_insights_read_your_journal_and_outcomes_6ec8aaf9": { + "en": "Insights read your journal and outcomes", + "ar": "الرؤى تقرأ يومياتك ونتائجك" + }, + "l10n_insights_screen_intervention_e9b90c40": { + "en": "Intervention", + "ar": "التدخّل" + }, + "l10n_insights_screen_log_at_least_one_behaviour_above_cf7c65a6": { + "en": "Log at least one behaviour above before starting an experiment.", + "ar": "سجّل سلوكًا واحدًا على الأقل أعلاه قبل بدء تجربة." + }, + "l10n_insights_screen_mark_done_0911cce7": { + "en": "Mark done", + "ar": "وضع علامة تم" + }, + "l10n_insights_screen_mark_done_today_471eb94a": { + "en": "Mark done today", + "ar": "وضع علامة تم اليوم" + }, + "l10n_insights_screen_next_morning_61d1ea83": { + "en": "Next morning", + "ar": "صباح اليوم التالي" + }, + "l10n_insights_screen_not_enough_overlap_between_your_journal_0ebdd7a2": { + "en": "Not enough overlap between your journal answers and ", + "ar": "لا يوجد تداخل كافٍ بين إجاباتك في اليوميات و" + }, + "l10n_insights_screen_not_enough_overlapping_history_to_correlate_a552dbd4": { + "en": "Not enough overlapping history to correlate your metrics yet.", + "ar": "لا يوجد سجل متداخل كافٍ لربط مقاييسك بعد." + }, + "l10n_insights_screen_pick_one_behaviour_you_log_one_bd34090e": { + "en": "Pick one behaviour you log, one outcome, and a short window. NOOP ", + "ar": "اختر سلوكًا واحدًا تسجّله، ونتيجة واحدة، ونافذة قصيرة. وNOOP " + }, + "l10n_insights_screen_pre_filled_from_last_night_tap_ce81097c": { + "en": "Pre-filled from last night. Tap to confirm or change.", + "ar": "مملوء من الليلة الماضية. اضغط للتأكيد أو التغيير." + }, + "l10n_insights_screen_ranked_lag_aware_which_of_your_e0e91b39": { + "en": "Ranked, lag-aware: which of your habits actually move your Charge, plus your personal alcohol/caffeine dose-response.", + "ar": "مرتَّب ومراعٍ للتأخير: أي عاداتك تحرّك شحنك فعلًا، مع استجابة الجرعة الشخصية للكحول/الكافيين." + }, + "l10n_insights_screen_reading_your_journal_and_outcomes_4a59af69": { + "en": "Reading your journal and outcomes…", + "ar": "جارٍ قراءة يومياتك ونتائجك…" + }, + "l10n_insights_screen_rest_b79e5f48": { + "en": "Rest", + "ar": "الراحة" + }, + "l10n_insights_screen_rest_baseline_b3ac52a5": { + "en": "Rest baseline", + "ar": "خط أساس الراحة" + }, + "l10n_insights_screen_rhr_04edf9b3": { + "en": "RHR", + "ar": "نبض الراحة" + }, + "l10n_insights_screen_run_a_clean_personal_test_4da69781": { + "en": "Run a clean personal test", + "ar": "أجرِ اختبارًا شخصيًا نظيفًا" + }, + "l10n_insights_screen_sessions_e11e37a9": { + "en": "Sessions", + "ar": "الجلسات" + }, + "l10n_insights_screen_skip_3da47453": { + "en": "Skip", + "ar": "تخطٍ" + }, + "l10n_insights_screen_skip_today_ed6a16a5": { + "en": "Skip today", + "ar": "تخطي اليوم" + }, + "l10n_insights_screen_snapshot_dayselapsed_of_snapshot_durationdays_days_9a611f12": { + "en": "%1$s of %2$s days", + "ar": "%1$s من %2$s يوم" + }, + "l10n_insights_screen_start_experiment_45a4b379": { + "en": "Start experiment", + "ar": "بدء التجربة" + }, + "l10n_insights_screen_started_snapshot_startday_testing_snapshot_outcome_1839ef40": { + "en": "Started %1$s · testing %2$s", + "ar": "بدأت %1$s · نختبر %2$s" + }, + "l10n_insights_screen_tag_a_few_sessions_of_the_97927401": { + "en": "Tag a few sessions of the same activity and NOOP will learn its personal recovery cost.", + "ar": "وسم بضع جلسات للنشاط نفسه وسيتعلّم NOOP تكلفتها الشخصية على التعافي." + }, + "l10n_insights_screen_what_moves_you_ranked_patterns_in_7d89e628": { + "en": "What moves you. Ranked patterns in your own data, and your dose-response.", + "ar": "ما الذي يحرّكك. أنماط مرتَّبة في بياناتك أنت، واستجابتك للجرعة." + }, + "l10n_insights_screen_with_564f8c6e": { + "en": "With", + "ar": "مع" + }, + "l10n_insights_screen_without_cb735356": { + "en": "Without", + "ar": "بدون" + }, + "l10n_intelligence_screen_0_unitformatter_effortscalemax_effortscale_scale_b5c43f56": { + "en": "0 - %1$s scale", + "ar": "مقياس 0 – %1$s" + }, + "l10n_intelligence_screen_by_day_2e5d14ca": { + "en": "By Day", + "ar": "حسب اليوم" + }, + "l10n_intelligence_screen_charge_weighs_your_heart_rate_variability_026745e6": { + "en": "Charge weighs your heart-rate variability against your personal baseline ", + "ar": "يوازن الشحن تقلب نبضك مقابل خط أساسك الشخصي " + }, + "l10n_intelligence_screen_effort_8c974bc6": { + "en": "Effort", + "ar": "الجهد" + }, + "l10n_intelligence_screen_estimate_from_today_s_effort_your_667d7560": { + "en": "Estimate from today\\'s effort, your typical sleep and your %1$s-night ", + "ar": "تقدير من جهد اليوم، ونومك المعتاد، و%1$s ليلة " + }, + "l10n_intelligence_screen_how_this_works_b895a8c3": { + "en": "How this works", + "ar": "كيف يعمل هذا" + }, + "l10n_intelligence_screen_intelligence_c698f940": { + "en": "Intelligence", + "ar": "الذكاء" + }, + "l10n_intelligence_screen_no_scored_days_in_this_window_03f1165b": { + "en": "No scored days in this window. Widen the range or import more history.", + "ar": "لا أيام محتسبة في هذه النافذة. وسّع النطاق أو استورد سجلًا أكثر." + }, + "l10n_intelligence_screen_no_scored_days_yet_sync_your_cdd47684": { + "en": "No scored days yet. Sync your strap to collect raw streams. Effort and Rest are ", + "ar": "لا أيام محتسبة بعد. زامن سوارك لجمع البيانات الخام. الجهد والراحة " + }, + "l10n_intelligence_screen_you_ll_likely_wake_around_charge_ed3abe2e": { + "en": "You\\'ll likely wake around %1$s ± %2$s Charge if you sleep about ", + "ar": "من المرجّح أن تستيقظ بشحن نحو %1$s ± %2$s إذا نمت قرابة " + }, + "l10n_intervals_screen_bond_your_strap_on_the_live_d799cbc2": { + "en": "Bond your strap on the Live screen to feel the transitions hands-free.", + "ar": "اقترن بسوارك من شاشة «مباشر» لتشعر بالانتقالات دون استخدام يديك." + }, + "l10n_intervals_screen_interval_timer_1d703deb": { + "en": "Interval Timer", + "ar": "مؤقّت الفترات" + }, + "l10n_intervals_screen_pause_to_change_work_rest_or_f54f9cc5": { + "en": "Pause to change work, rest, or rounds.", + "ar": "أوقف مؤقتًا لتغيير العمل أو الراحة أو الجولات." + }, + "l10n_intervals_screen_reset_44c57abd": { + "en": "Reset", + "ar": "إعادة تعيين" + }, + "l10n_intervals_screen_rest_b79e5f48": { + "en": "Rest", + "ar": "راحة" + }, + "l10n_intervals_screen_rounds_ceeac4ac": { + "en": "Rounds", + "ar": "الجولات" + }, + "l10n_intervals_screen_work_00040bab": { + "en": "Work", + "ar": "عمل" + }, + "l10n_journal_log_add_a_custom_item_0dbd8f7c": { + "en": "Add a custom item…", + "ar": "إضافة عنصر مخصص…" + }, + "l10n_journal_log_cancel_77dfd213": { + "en": "Cancel", + "ar": "إلغاء" + }, + "l10n_journal_log_display_name_c7874aaa": { + "en": "Display name", + "ar": "الاسم المعروض" + }, + "l10n_journal_log_group_995a11e5": { + "en": "Group…", + "ar": "مجموعة…" + }, + "l10n_journal_log_group_group_title_1f88621e": { + "en": "Group: %1$s", + "ar": "المجموعة: %1$s" + }, + "l10n_journal_log_history_stays_under_the_original_question_dc91ce89": { + "en": "History stays under the original question so WHOOP imports still line up.", + "ar": "يبقى السجل تحت السؤال الأصلي حتى تظل عمليات استيراد WHOOP متوافقة." + }, + "l10n_journal_log_journal_57d7f743": { + "en": "Journal", + "ar": "اليوميات" + }, + "l10n_journal_log_rename_94ac9a58": { + "en": "Rename…", + "ar": "إعادة تسمية…" + }, + "l10n_journal_log_rename_item_3d21d6ca": { + "en": "Rename item", + "ar": "إعادة تسمية العنصر" + }, + "l10n_journal_log_save_efc007a3": { + "en": "Save", + "ar": "حفظ" + }, + "l10n_journal_reminder_journal_57d7f743": { + "en": "Journal", + "ar": "اليوميات" + }, + "l10n_journal_reminder_journal_reminder_0fdc0d9c": { + "en": "Journal reminder", + "ar": "تذكير اليوميات" + }, + "l10n_journal_reminder_log_today_s_journal_cb33be3e": { + "en": "Log today\\'s journal", + "ar": "سجّل يوميات اليوم" + }, + "l10n_journal_reminder_logged_today_0071a46c": { + "en": "Logged today", + "ar": "سُجِّل اليوم" + }, + "l10n_journal_reminder_open_journal_bbd88cc1": { + "en": "Open journal", + "ar": "فتح اليوميات" + }, + "l10n_journal_reminder_show_a_today_card_reminding_you_to_log_your_journal_8228bc77": { + "en": "Show a Today card reminding you to log your journal", + "ar": "عرض بطاقة في «اليوم» تذكّرك بتسجيل يومياتك" + }, + "l10n_journal_reminder_tap_a_day_to_catch_up_10d16728": { + "en": "Tap a day to catch up", + "ar": "اضغط على يوم لتعويض ما فاتك" + }, + "l10n_lab_book_screen_a_private_notebook_not_a_medical_f63242cb": { + "en": "A private notebook, not a medical service.", + "ar": "دفتر خاص، لا خدمة طبية." + }, + "l10n_lab_book_screen_about_lab_book_37bf2691": { + "en": "About Lab Book", + "ar": "عن دفتر المختبر" + }, + "l10n_lab_book_screen_all_stays_on_this_phone_nothing_9a966c1c": { + "en": "All stays on this phone. Nothing is sent anywhere.", + "ar": "كل شيء يبقى على هذا الهاتف. لا يُرسل شيء إلى أي مكان." + }, + "l10n_lab_book_screen_bring_in_a_markers_csv_date_1090bec2": { + "en": "Bring in a markers CSV (date, marker, value, unit). Names that match the catalog ", + "ar": "استورد ملف CSV للمؤشرات (التاريخ، المؤشر، القيمة، الوحدة). الأسماء المطابقة للفهرس " + }, + "l10n_lab_book_screen_choose_a_wearable_signal_to_compare_05a4b2bc": { + "en": "Choose a wearable signal to compare", + "ar": "اختر إشارة من الجهاز الملبوس للمقارنة" + }, + "l10n_lab_book_screen_clear_719ea396": { + "en": "Clear", + "ar": "مسح" + }, + "l10n_lab_book_screen_delete_this_reading_b5c92bda": { + "en": "Delete this reading", + "ar": "حذف هذه القراءة" + }, + "l10n_lab_book_screen_import_readings_7ad0d28f": { + "en": "Import readings", + "ar": "استيراد قراءات" + }, + "l10n_lab_book_screen_it_s_a_notebook_not_a_45835e98": { + "en": "It\\'s a notebook, not a lab. NOOP lines up the numbers you enter. It doesn\\'t test, read, or judge them. Not medical advice.", + "ar": "إنه دفتر، لا مختبر. يرتّب NOOP الأرقام التي تدخلها. لا يجري تحاليل ولا يقرأها ولا يحكم عليها. وليس نصيحة طبية." + }, + "l10n_lab_book_screen_keep_your_own_numbers_here_1ca6c8ed": { + "en": "Keep your own numbers here", + "ar": "احتفظ بأرقامك هنا" + }, + "l10n_lab_book_screen_lab_book_f966c140": { + "en": "Lab Book", + "ar": "دفتر المختبر" + }, + "l10n_lab_book_screen_lab_book_is_a_private_notebook_d07380ca": { + "en": "Lab Book is a private notebook, not a medical service. NOOP stores and lines up the numbers ", + "ar": "دفتر المختبر دفتر خاص، لا خدمة طبية. يخزّن NOOP الأرقام ويرتّبها " + }, + "l10n_lab_book_screen_lining_them_up_c59ee297": { + "en": "Lining them up…", + "ar": "جارٍ ترتيبها…" + }, + "l10n_lab_book_screen_n_readings_line_up_but_there_27bfb506": { + "en": "%1$s readings line up, but there isn\\'t enough variation to compute a relationship.", + "ar": "%1$s قراءة مرتَّبة، لكن التباين غير كافٍ لحساب علاقة." + }, + "l10n_lab_book_screen_n_readings_used_strengthword_r_directionword_298baf9e": { + "en": "%1$s readings used · %2$s %3$s association. This is your own data ", + "ar": "%1$s قراءة مستخدمة · ارتباط %2$s %3$s. هذه بياناتك أنت " + }, + "l10n_lab_book_screen_pick_a_wearable_signal_resting_hr_8d8b01a2": { + "en": "Pick a wearable signal (resting HR, HRV, sleep, Charge, weight…) to line it up against this ", + "ar": "اختر إشارة من الجهاز الملبوس (نبض الراحة، تقلب النبض، النوم، الشحن، الوزن…) لترتيبها مقابل هذا " + }, + "l10n_lab_book_screen_read_the_full_lab_book_note_3a250750": { + "en": "Read the full Lab Book note", + "ar": "اقرأ ملاحظة دفتر المختبر كاملة" + }, + "l10n_lab_book_screen_read_the_full_note_3fd0e4d1": { + "en": "Read the full note", + "ar": "اقرأ الملاحظة كاملة" + }, + "l10n_lab_book_screen_reading_your_logbook_e31992b0": { + "en": "Reading your logbook…", + "ar": "جارٍ قراءة دفترك…" + }, + "l10n_lab_book_screen_these_are_your_own_numbers_shown_7667508d": { + "en": "These are your own numbers shown back to you. NOOP doesn\\'t decide whether any value is normal, high or low.", + "ar": "هذه أرقامك أنت معروضة عليك. ولا يقرر NOOP ما إذا كانت أي قيمة طبيعية أو مرتفعة أو منخفضة." + }, + "l10n_lab_book_screen_type_in_a_blood_pressure_reading_c276d418": { + "en": "Type in a blood-pressure reading or a cholesterol value from your last appointment. ", + "ar": "أدخل قراءة ضغط دم أو قيمة كوليسترول من موعدك الأخير. " + }, + "l10n_lab_book_screen_what_lab_book_is_and_isn_f28686df": { + "en": "What Lab Book is (and isn\\'t)", + "ar": "ما هو دفتر المختبر (وما ليس هو)" + }, + "l10n_live_screen_avg_cdc93143": { + "en": "Avg", + "ar": "متوسط" + }, + "l10n_live_screen_buzz_edbd47b2": { + "en": "Buzz", + "ar": "اهتزاز" + }, + "l10n_live_screen_can_t_connect_your_strap_s_cf78be83": { + "en": "Can\\'t connect - your strap\\'s pairing was reset", + "ar": "تعذّر الاتصال — أُعيد ضبط اقتران سوارك" + }, + "l10n_live_screen_charging_5f99fe21": { + "en": "Charging", + "ar": "قيد الشحن" + }, + "l10n_live_screen_distance_42320809": { + "en": "Distance", + "ar": "المسافة" + }, + "l10n_live_screen_effort_8c974bc6": { + "en": "Effort", + "ar": "الجهد" + }, + "l10n_live_screen_end_a2bb9d34": { + "en": "End", + "ar": "إنهاء" + }, + "l10n_live_screen_end_workout_3e8d6238": { + "en": "End workout", + "ar": "إنهاء التمرين" + }, + "l10n_live_screen_history_synced_relativeago_at_0ece753a": { + "en": "History synced %1$s", + "ar": "تمت مزامنة السجل %1$s" + }, + "l10n_live_screen_hr_f187928f": { + "en": "HR", + "ar": "النبض" + }, + "l10n_live_screen_live_body_console_54838e06": { + "en": "Live Body Console", + "ar": "لوحة الجسد المباشرة" + }, + "l10n_live_screen_manage_devices_e5c277ff": { + "en": "Manage devices", + "ar": "إدارة الأجهزة" + }, + "l10n_live_screen_max_hr_6acd9e07": { + "en": "Max HR", + "ar": "أقصى نبض" + }, + "l10n_live_screen_pace_7a9a6226": { + "en": "Pace", + "ar": "الوتيرة" + }, + "l10n_live_screen_peak_c83dbbd3": { + "en": "Peak", + "ar": "الذروة" + }, + "l10n_live_screen_refresh_56e3badc": { + "en": "Refresh", + "ar": "تحديث" + }, + "l10n_live_screen_rmssd_roundtoint_ms_9e6c0887": { + "en": "%1$s ms", + "ar": "%1$s مللي ثانية" + }, + "l10n_live_screen_scan_and_connect_to_start_a_3e27b47b": { + "en": "Scan and connect to start a live stream.", + "ar": "ابحث واتصل لبدء بث مباشر." + }, + "l10n_live_screen_session_f7f1997c": { + "en": "Session", + "ar": "جلسة" + }, + "l10n_live_screen_signal_trust_4a91fe00": { + "en": "Signal Trust", + "ar": "موثوقية الإشارة" + }, + "l10n_live_screen_start_a_live_stream_419bf5aa": { + "en": "Start a live stream", + "ar": "بدء بث مباشر" + }, + "l10n_live_screen_start_workout_d0f3f2cd": { + "en": "Start workout", + "ar": "بدء تمرين" + }, + "l10n_live_screen_strap_02b88eeb": { + "en": "Strap", + "ar": "السوار" + }, + "l10n_live_screen_take_an_hrv_reading_20ecb2d2": { + "en": "Take an HRV reading", + "ar": "إجراء قراءة لتقلب النبض" + }, + "l10n_live_screen_top_zone_282d0f66": { + "en": "Top zone", + "ar": "المنطقة العليا" + }, + "l10n_live_screen_whoop_5_0_mg_pairs_with_b93143f2": { + "en": "WHOOP 5.0/MG pairs with one app at a time. If a scan finds nothing, unpair it in ", + "ar": "يقترن WHOOP 5.0/MG بتطبيق واحد في كل مرة. إذا لم يجد البحث شيئًا، فألغِ اقترانه في " + }, + "l10n_live_screen_zone_zone_b8e9c0f9": { + "en": "ZONE %1$s", + "ar": "المنطقة %1$s" + }, + "l10n_live_session_screen_above_6370c271": { + "en": "Above", + "ar": "أعلى" + }, + "l10n_live_session_screen_below_5ba07a31": { + "en": "Below", + "ar": "أدنى" + }, + "l10n_live_session_screen_done_e9b450d1": { + "en": "Done", + "ar": "تم" + }, + "l10n_live_session_screen_end_session_8c0f4c33": { + "en": "End session", + "ar": "إنهاء الجلسة" + }, + "l10n_live_session_screen_guarded_for_elapsedclock_snap_elapsedsec_tolong_a61f2d32": { + "en": "Guarded for %1$s", + "ar": "تحت الحماية %1$s" + }, + "l10n_live_session_screen_in_band_2c23d86e": { + "en": "In band", + "ar": "ضمن النطاق" + }, + "l10n_live_session_screen_live_session_73c925a5": { + "en": "Live Session", + "ar": "جلسة مباشرة" + }, + "l10n_live_session_screen_session_ring_statelabel_long_press_to_42ac1b3b": { + "en": "Session ring. %1$s. Long-press to show heart rate.", + "ar": "حلقة الجلسة. %1$s. اضغط مطولًا لعرض معدل النبض." + }, + "l10n_live_session_screen_session_summary_f9418e16": { + "en": "Session summary", + "ar": "ملخص الجلسة" + }, + "l10n_live_session_screen_the_strap_signal_was_gone_for_57a2fead": { + "en": "The strap signal was gone for 10 minutes, so the session ended itself.", + "ar": "انقطعت إشارة السوار لعشر دقائق، فأنهت الجلسة نفسها." + }, + "l10n_live_workout_screen_avg_cdc93143": { + "en": "Avg", + "ar": "متوسط" + }, + "l10n_live_workout_screen_cadence_68af11f0": { + "en": "Cadence", + "ar": "الإيقاع" + }, + "l10n_live_workout_screen_cancel_77dfd213": { + "en": "Cancel", + "ar": "إلغاء" + }, + "l10n_live_workout_screen_effort_8c974bc6": { + "en": "Effort", + "ar": "الجهد" + }, + "l10n_live_workout_screen_end_this_workout_4869c76a": { + "en": "End this workout?", + "ar": "إنهاء هذا التمرين؟" + }, + "l10n_live_workout_screen_end_workout_3e8d6238": { + "en": "End workout", + "ar": "إنهاء التمرين" + }, + "l10n_live_workout_screen_of_c5e92da0": { + "en": "of %1$d", + "ar": "من %1$d" + }, + "l10n_live_workout_screen_peak_c83dbbd3": { + "en": "Peak", + "ar": "الذروة" + }, + "l10n_live_workout_screen_power_7548ab52": { + "en": "Power", + "ar": "القدرة" + }, + "l10n_live_workout_screen_speed_2d2cb022": { + "en": "Speed", + "ar": "السرعة" + }, + "l10n_live_workout_screen_this_stops_recording_and_saves_what_3e17a23e": { + "en": "This stops recording and saves what\\'s captured so far. It can\\'t be resumed.", + "ar": "يوقف هذا التسجيل ويحفظ ما التُقط حتى الآن. ولا يمكن استئنافه." + }, + "l10n_live_workout_screen_z_z_d3991a28": { + "en": "Z%1$s", + "ar": "م%1$s" + }, + "l10n_marker_editor_screen_add_a_reading_ed7708ee": { + "en": "Add a reading", + "ar": "إضافة قراءة" + }, + "l10n_marker_editor_screen_date_taken_76edfcab": { + "en": "Date taken", + "ar": "تاريخ القياس" + }, + "l10n_marker_editor_screen_entered_together_stored_as_two_markers_af8f3ba0": { + "en": "Entered together; stored as two markers so each lines up cleanly against your signals.", + "ar": "أُدخلا معًا؛ ويُخزَّنان كمؤشرين لينتظم كل منهما بوضوح مقابل إشاراتك." + }, + "l10n_marker_editor_screen_lab_book_keeps_your_own_numbers_9085ce2c": { + "en": "Lab Book keeps your own numbers - it doesn\\'t test, read, or judge them, and it\\'s not medical ", + "ar": "يحتفظ دفتر المختبر بأرقامك أنت — لا يجري تحاليل ولا يقرأها ولا يحكم عليها، وليس طبيًا " + }, + "l10n_marker_editor_screen_no_match_add_it_as_a_ad5c59ec": { + "en": "No match. Add it as a custom marker below.", + "ar": "لا تطابق. أضفه كمؤشر مخصص أدناه." + }, + "l10n_marker_editor_screen_noop_never_fills_this_in_it_7be9653b": { + "en": "NOOP never fills this in - it only shows back exactly what you type from your own report.", + "ar": "لا يملأ NOOP هذا أبدًا — بل يعرض بالضبط ما تدخله من تقريرك أنت." + }, + "l10n_marker_editor_screen_search_markers_103981e0": { + "en": "Search markers", + "ar": "البحث في المؤشرات" + } + }, + "_glossary_note": "Terminology anchor, not a literal expectation. Arabic inflects - the definite article, prepositions and verb forms all alter the stored surface form - so a plain substring check will under-report these terms. Keep the terms, or change them everywhere at once." +} diff --git a/Tools/i18n_audit.py b/Tools/i18n_audit.py index 85a2fe94e9..a2f8826289 100644 --- a/Tools/i18n_audit.py +++ b/Tools/i18n_audit.py @@ -435,7 +435,7 @@ def record(span_start: int, span_end: int) -> None: continue seen.add(offset) line_no = text.count("\n", 0, offset) + 1 - findings.append((str(path.relative_to(ROOT)), line_no, literal)) + findings.append((path.relative_to(ROOT).as_posix(), line_no, literal)) # The call's own first (content) argument only — catches # `Text(if (x) "a" else "b")` — never the whole call span, which @@ -856,7 +856,7 @@ def scan_ios() -> tuple[list[tuple[str, int, str]], dict[str, list[str]]]: continue entry = swift_catalog_lookup(cat, literal) line_no = text.count("\n", 0, offset) + 1 - rel = str(path.relative_to(ROOT)) + rel = path.relative_to(ROOT).as_posix() if entry is None: hardcoded.append((rel, line_no, literal)) continue @@ -864,7 +864,7 @@ def scan_ios() -> tuple[list[tuple[str, int, str]], dict[str, list[str]]]: continue for lang in LANGS: if not _is_translated(entry, lang): - lang_gaps[lang].append(f"{catalog_path.relative_to(ROOT)} :: {literal!r}") + lang_gaps[lang].append(f"{catalog_path.relative_to(ROOT).as_posix()} :: {literal!r}") for lang in lang_gaps: lang_gaps[lang] = sorted(set(lang_gaps[lang])) return hardcoded, lang_gaps @@ -926,13 +926,33 @@ def extra_locale_allowance() -> dict[str, int]: FORMAT_SPECIFIER_PATTERN = re.compile(r"%(?:\d+\$)?[@#0\-+ ]*[\d.]*(?:ll|l|h)?[@dfsu]|%%") +# Multi-word product names that travel verbatim into Latin-script locales. The two-word floor below +# already lets a ONE-word brand through ("HRV", "Strava"); it cannot see a two-word one, so "iCloud +# Drive" repeated verbatim in German reads as an untranslated echo when it is the correct rendering. +# Only strings that are ENTIRELY brand are exempted (see `_is_pure_brand_phrase`), so "Apple Health +# sync" stays gated on its translatable word. CJK locales that DO translate these are unaffected — +# they differ from the source, so they were never counted as echoes in the first place. +BRAND_PHRASES = ("iCloud Drive",) + + +def _is_pure_brand_phrase(text: str) -> bool: + """Whether a string is nothing but brand names, placeholders and punctuation.""" + stripped = FORMAT_SPECIFIER_PATTERN.sub(" ", text) + for brand in BRAND_PHRASES: + stripped = stripped.replace(brand, " ") + return not re.search(r"[^\W\d_]{2,}", stripped, flags=re.UNICODE) + + def _has_translatable_words(text: str) -> bool: """Whether a string carries enough real words that an identical translation is suspicious. Strips format specifiers first: "%@ · n = %lld" / "%1$s: %2$s" are placeholders and punctuation with nothing to translate, so a locale repeating them verbatim is CORRECT, not a gap. Two words is the floor — one word is very often a term that legitimately travels ("HRV", "Yoga", a brand name). + A string that is entirely a multi-word brand is the same case one size up (see [BRAND_PHRASES]). """ + if _is_pure_brand_phrase(text): + return False stripped = FORMAT_SPECIFIER_PATTERN.sub(" ", text) return len(re.findall(r"[^\W\d_]{2,}", stripped, flags=re.UNICODE)) >= 2 @@ -948,7 +968,7 @@ def _ios_echoed_counts() -> dict[str, int]: cat = json.loads(catalog_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): continue - rel = str(catalog_path.relative_to(ROOT)) + rel = catalog_path.relative_to(ROOT).as_posix() for key, entry in (cat.get("strings") or {}).items(): if not _has_translatable_words(key): continue @@ -984,7 +1004,7 @@ def _android_echoed_counts() -> dict[str, int]: loc = {n.attrib["name"]: (n.text or "") for n in ET.parse(path).getroot().findall("string")} except ET.ParseError: continue - rel = str(path.relative_to(ROOT)) + rel = path.relative_to(ROOT).as_posix() n = sum(1 for k, base_val in translatable.items() if loc.get(k) == base_val) if n: counts[f"{rel} {lang}"] = n @@ -1126,7 +1146,7 @@ def ci_check(base_ref: str) -> int: # and gate them below. Reloading each catalog for a second pass wasted a full re-parse of a # 3255-string file. for extra in sorted(shipped_apple_langs(cat) - set(LANGS)): - extra_apple_gaps[f"{catalog_path.relative_to(ROOT)}:{extra}"] = sum( + extra_apple_gaps[f"{catalog_path.relative_to(ROOT).as_posix()}:{extra}"] = sum( 1 for v in cat.get("strings", {}).values() if v.get("shouldTranslate") is not False and not _is_translated(v, extra) ) diff --git a/Tools/i18n_extra_locale_baseline.txt b/Tools/i18n_extra_locale_baseline.txt index d854b49885..6496f6267d 100644 --- a/Tools/i18n_extra_locale_baseline.txt +++ b/Tools/i18n_extra_locale_baseline.txt @@ -4,7 +4,7 @@ # tolerance in LANGS, which is why they are at 100%. Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings:it 81 -Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings:ru 54 +Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings:ru 10 Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings:zh-Hans 75 Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings:zh-Hant 81 NOOPWatch/Localizable.xcstrings:it 0 @@ -15,7 +15,8 @@ NOOPWatchComplications/Localizable.xcstrings:it 25 NOOPWatchComplications/Localizable.xcstrings:zh-Hans 25 NOOPWatchComplications/Localizable.xcstrings:zh-Hant 25 Strand/Resources/Localizable.xcstrings:it 179 -Strand/Resources/Localizable.xcstrings:ru 156 +Strand/Resources/Localizable.xcstrings:ru 1 Strand/Resources/Localizable.xcstrings:zh-Hans 129 Strand/Resources/Localizable.xcstrings:zh-Hant 179 +values-ru/strings.xml 121 values-zh/strings.xml 49 diff --git a/Tools/linux-capture/analyze_command_responses.py b/Tools/linux-capture/analyze_command_responses.py index d4ba5c7bff..b6fe9774f0 100644 --- a/Tools/linux-capture/analyze_command_responses.py +++ b/Tools/linux-capture/analyze_command_responses.py @@ -11,6 +11,7 @@ import json import sys +from capture_io import configure_utf8_stdio import whoop_frame as wf # Response command numbers (CommandNumber enum) → human name. @@ -45,7 +46,8 @@ def main(): if len(sys.argv) < 2: print(__doc__) return - data = json.load(open(sys.argv[1])) + with open(sys.argv[1], encoding="utf-8") as f: + data = json.load(f) seen = {} for r in data: h = bytes.fromhex(r["hex"]) @@ -67,4 +69,5 @@ def main(): if __name__ == "__main__": + configure_utf8_stdio() main() diff --git a/Tools/linux-capture/analyze_v25_waveform.py b/Tools/linux-capture/analyze_v25_waveform.py index e1f78bedaf..52952abb42 100644 --- a/Tools/linux-capture/analyze_v25_waveform.py +++ b/Tools/linux-capture/analyze_v25_waveform.py @@ -52,6 +52,8 @@ import struct import sys +from capture_io import configure_utf8_stdio + # --- v25 record geometry (WHOOP 4.0 envelope: 0xAA, len u16, crc8, type@4, version@5, ...) ----------- TYPE_HISTORICAL = 0x2F # 47, at byte 4 VERSION_V25 = 25 # at byte 5 @@ -164,7 +166,8 @@ def load_corpus(argv): print("No corpus given — running the DEMO on the bundled resting frames (which cannot pin the\n" "span; that is the point). Build a real corpus (--template) with a HR ≠ 60.\n") return [BUNDLED_RESTING] - return json.load(open(paths[0])) + with open(paths[0], encoding="utf-8") as f: + return json.load(f) def analyze(corpus): @@ -266,6 +269,7 @@ def selftest(): if __name__ == "__main__": + configure_utf8_stdio() if "--selftest" in sys.argv: selftest() else: diff --git a/Tools/linux-capture/analyze_v26_waveform.py b/Tools/linux-capture/analyze_v26_waveform.py index e56c16aac3..430a474e81 100644 --- a/Tools/linux-capture/analyze_v26_waveform.py +++ b/Tools/linux-capture/analyze_v26_waveform.py @@ -26,6 +26,8 @@ import sys from collections import Counter +from capture_io import configure_utf8_stdio + WAVE_START, WAVE_END = 27, 75 # 24 LE-i16 samples per record SAMPLE_RATE_HZ = 24 # 24 samples/record, 1 record/second @@ -56,7 +58,8 @@ def acf(x, lag): def main(): path = sys.argv[1] if len(sys.argv) > 1 else "capture_hist_ack.json" - frames = [bytes.fromhex(c["hex"]) for c in json.load(open(path))] + with open(path, encoding="utf-8") as f: + frames = [bytes.fromhex(c["hex"]) for c in json.load(f)] v26 = [r for r in frames if len(r) == 88 and r[8] == 47 and r[9] == 26] v18 = [r for r in frames if len(r) == 124 and r[8] == 47 and r[9] == 18] if not v26: @@ -102,4 +105,5 @@ def main(): if __name__ == "__main__": + configure_utf8_stdio() main() diff --git a/Tools/linux-capture/capture_io.py b/Tools/linux-capture/capture_io.py new file mode 100644 index 0000000000..045065373d --- /dev/null +++ b/Tools/linux-capture/capture_io.py @@ -0,0 +1,11 @@ +"""Shared text-console setup for the executable Linux capture tools.""" + +import sys + + +def configure_utf8_stdio() -> None: + """Keep Unicode diagnostics printable when the host defaults to a legacy code page.""" + for stream in (sys.stdout, sys.stderr): + reconfigure = getattr(stream, "reconfigure", None) + if callable(reconfigure): + reconfigure(encoding="utf-8") diff --git a/Tools/linux-capture/correlate_ground_truth.py b/Tools/linux-capture/correlate_ground_truth.py index 16434e5191..b5aab754b9 100644 --- a/Tools/linux-capture/correlate_ground_truth.py +++ b/Tools/linux-capture/correlate_ground_truth.py @@ -29,6 +29,7 @@ import sys import zipfile +from capture_io import configure_utf8_stdio import whoop_frame as wf # --- Ground-truth CSV loading ---------------------------------------------------------------------- @@ -301,7 +302,7 @@ def main(): help="restrict to one record type byte, e.g. 0x2f") args = p.parse_args() - with open(args.capture) as f: + with open(args.capture, encoding="utf-8") as f: records = json.load(f) rows = load_ground_truth(args.export) if not rows: @@ -344,4 +345,5 @@ def main(): if __name__ == "__main__": + configure_utf8_stdio() main() diff --git a/Tools/linux-capture/decode_features.py b/Tools/linux-capture/decode_features.py index 132612be57..0a81b80fd4 100644 --- a/Tools/linux-capture/decode_features.py +++ b/Tools/linux-capture/decode_features.py @@ -81,7 +81,11 @@ def rr_stats(rr): def feature_to_rows(rec): """Map a normalized decoded record -> {second, rr[], ppg[], event}. See module/spec for rules.""" out = {"second": None, "rr": [], "ppg": [], "event": None} - if not rec.get("crc_ok", True): + # The gate is the decoder's FULL verdict (`frame.ok`: start-of-frame, minimum and exact length, + # header checksum and payload CRC32 together), not the payload CRC alone — a frame whose CRC32 + # verifies but whose envelope does not is exactly the class that used to reach these tables. A + # record without a verdict is rejected, never assumed to pass. + if not rec.get("ok", False): return out p = rec.get("parsed") or {} itype = rec.get("inner_type") @@ -190,11 +194,16 @@ def _flatten_fields(frame): def normalize_decode_record(raw, src_frame, device_id): """Adapt one whoop-decode --json object + its source frames row -> the normalized record dict. - whoop-decode emits {family, char, frame:{crcOK, seq, fields:[{name,value,cat,off},...]}}. We - flatten frame.fields (minus envelope/raw cats) into `parsed`, take crc from frame.crcOK, version - from the `hist_version` field (whoop4 historical has no such field and is always v24), take unix - from the decoded `unix` field (falling back to the source frames row), and inner_type from the - source frames row (id,hex,char,recv_ms,unix,hr,inner_type). + whoop-decode emits {family, char, frame:{ok, crcOK, rejectReason, seq, + fields:[{name,value,cat,off},...]}}. We flatten frame.fields (minus envelope/raw cats) into + `parsed`, take the integrity verdict from frame.ok, version from the `hist_version` field (whoop4 + historical has no such field and is always v24), take unix from the decoded `unix` field (falling + back to the source frames row), and inner_type from the source frames row + (id,hex,char,recv_ms,unix,hr,inner_type). + + `frame.ok` is the decoder's full verdict; `frame.crcOK` reports the payload CRC32 alone and is + therefore not the gate. A missing verdict yields False: absence of the field is a rejection, not + a pass. (`frame.rejectReason` names the cause and is available for diagnosis.) """ raw = raw if isinstance(raw, dict) else {} frame = raw.get("frame") or {} @@ -205,7 +214,7 @@ def normalize_decode_record(raw, src_frame, device_id): rr = (frame.get("parsed") or {}).get("rr_intervals") if rr is not None: parsed["rr_intervals"] = rr - crc_ok = bool(frame.get("crcOK", True)) + ok = bool(frame.get("ok", False)) version = parsed.get("hist_version") if version is None and raw.get("family") == "whoop4": version = 24 @@ -215,7 +224,7 @@ def normalize_decode_record(raw, src_frame, device_id): if unix is None: unix = src_frame[4] return {"device_id": device_id, "unix": unix, "inner_type": src_frame[6], - "version": version, "crc_ok": crc_ok, "parsed": parsed} + "version": version, "ok": ok, "parsed": parsed} def find_whoop_decode(): @@ -242,7 +251,7 @@ def run_whoop_decode(frames_json, binary=None): binary = binary or find_whoop_decode() fd, path = tempfile.mkstemp(suffix=".json") try: - with os.fdopen(fd, "w") as f: + with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(frames_json, f) r = subprocess.run([binary, "--json", "--family", "auto", path], capture_output=True, text=True) diff --git a/Tools/linux-capture/hci_extract.py b/Tools/linux-capture/hci_extract.py index 27f5414b70..4342ca4623 100644 --- a/Tools/linux-capture/hci_extract.py +++ b/Tools/linux-capture/hci_extract.py @@ -23,6 +23,7 @@ import struct import sys +from capture_io import configure_utf8_stdio import whoop_frame as wf # --- HCI log container parsing --------------------------------------------------------------------- @@ -289,7 +290,7 @@ def main(): args = p.parse_args() records, stats = extract(load_hci(args.input), args.family) - with open(args.out, "w") as f: + with open(args.out, "w", encoding="utf-8") as f: json.dump(records, f, indent=1) if not records: @@ -309,4 +310,5 @@ def main(): if __name__ == "__main__": + configure_utf8_stdio() main() diff --git a/Tools/linux-capture/pair_probe.py b/Tools/linux-capture/pair_probe.py index dbc91b1ab5..7fd46da5a0 100644 --- a/Tools/linux-capture/pair_probe.py +++ b/Tools/linux-capture/pair_probe.py @@ -2,6 +2,7 @@ Usage: python3 pair_probe.py (or set WHOOP_MAC). Use on a strap you own; phone BT off.""" import asyncio, os, sys, time from bleak import BleakClient, BleakScanner +from capture_io import configure_utf8_stdio MAC = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("WHOOP_MAC", "") if not MAC: sys.exit("usage: python3 pair_probe.py (or export WHOOP_MAC=AA:BB:CC:DD:EE:FF)") @@ -50,4 +51,5 @@ async def main(): except Exception as e: log(f"connect FAILED: {type(e).__name__}: {str(e)[:110]}") +configure_utf8_stdio() asyncio.run(main()) diff --git a/Tools/linux-capture/test_console_encoding.py b/Tools/linux-capture/test_console_encoding.py new file mode 100644 index 0000000000..7bae241f11 --- /dev/null +++ b/Tools/linux-capture/test_console_encoding.py @@ -0,0 +1,62 @@ +"""Executable-path coverage for consoles that default to a legacy Windows encoding. + +The subprocesses run on Linux with strict cp1252 stdout/stderr. This reproduces the encoding +constraint but is not a native Windows test. +""" + +import os +import sqlite3 +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +HERE = Path(__file__).resolve().parent + + +def _run_cp1252(script, *args): + env = os.environ.copy() + env["PYTHONIOENCODING"] = "cp1252:strict" + return subprocess.run( + [sys.executable, str(HERE / script), *map(str, args)], + cwd=HERE, + env=env, + capture_output=True, + timeout=10, + ) + + +class ConsoleEncodingTests(unittest.TestCase): + def test_analyze_v25_real_entrypoint_is_unicode_safe(self): + result = _run_cp1252("analyze_v25_waveform.py") + self.assertEqual(result.returncode, 0, result.stderr.decode("utf-8", errors="replace")) + self.assertIn("running the DEMO", result.stdout.decode("utf-8")) + + def test_whoop_sync_real_entrypoint_is_unicode_safe(self): + with tempfile.TemporaryDirectory() as td: + db_path = os.path.join(td, "capture.db") + con = sqlite3.connect(db_path) + con.execute( + "CREATE TABLE devices (id INTEGER PRIMARY KEY, address TEXT NOT NULL UNIQUE, " + "name TEXT, subject TEXT, model TEXT, created_ms INTEGER)" + ) + con.execute( + "CREATE TABLE frames (id INTEGER PRIMARY KEY, device_id INTEGER NOT NULL, " + "recv_ms INTEGER NOT NULL, char TEXT, inner_type INTEGER, unix INTEGER, " + "hr INTEGER, hex TEXT NOT NULL, UNIQUE(device_id, hex))" + ) + con.execute( + "INSERT INTO devices(address, name, model) VALUES('AA:BB:CC:DD:EE:FF', 'strap', 'whoop4')" + ) + con.commit() + con.close() + + result = _run_cp1252("whoop_sync.py", "devices", "--db", db_path) + self.assertEqual(result.returncode, 0, result.stderr.decode("utf-8", errors="replace")) + self.assertIn("→", result.stdout.decode("utf-8")) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/linux-capture/test_correlate_ground_truth.py b/Tools/linux-capture/test_correlate_ground_truth.py index dafdb2f215..9c4b6eceda 100644 --- a/Tools/linux-capture/test_correlate_ground_truth.py +++ b/Tools/linux-capture/test_correlate_ground_truth.py @@ -61,7 +61,7 @@ def test_load_english_cycles_spo2(self): "2026-07-14 23:51:24,97.29,34.40\n" ) folder = tempfile.mkdtemp() - with open(os.path.join(folder, "physiological_cycles.csv"), "w") as f: + with open(os.path.join(folder, "physiological_cycles.csv"), "w", encoding="utf-8") as f: f.write(cycles) rows = cg.load_ground_truth(folder) truth = cg.truth_values(rows) @@ -72,9 +72,9 @@ def test_merges_cycles_and_sleep_on_cycle_start(self): cycles = "Startzeit des Zyklus,Herzfrequenzvariabilität (ms)\n2026-06-20 00:24:31,92\n" sleep = "Startzeit des Zyklus,Atemfrequenz (Atemzüge/Min.)\n2026-06-20 00:24:31,14.6\n" folder = tempfile.mkdtemp() - with open(os.path.join(folder, "physiologische_zyklen.csv"), "w") as f: + with open(os.path.join(folder, "physiologische_zyklen.csv"), "w", encoding="utf-8") as f: f.write(cycles) - with open(os.path.join(folder, "Schlaf.csv"), "w") as f: + with open(os.path.join(folder, "Schlaf.csv"), "w", encoding="utf-8") as f: f.write(sleep) rows = cg.load_ground_truth(folder) self.assertEqual(len(rows), 1) diff --git a/Tools/linux-capture/test_decode_features.py b/Tools/linux-capture/test_decode_features.py index 124b077a69..75b9af2122 100644 --- a/Tools/linux-capture/test_decode_features.py +++ b/Tools/linux-capture/test_decode_features.py @@ -9,27 +9,29 @@ import decode_features as df -def _conn(): +def _conn(owner=None): c = sqlite3.connect(":memory:") df.apply_schema(c) + if owner is not None: + owner.addCleanup(c.close) return c class SchemaTests(unittest.TestCase): def test_creates_four_feature_tables(self): - c = _conn() + c = _conn(self) names = {r[0] for r in c.execute( "SELECT name FROM sqlite_master WHERE type='table'").fetchall()} self.assertTrue({"feat_second", "feat_rr", "feat_ppg", "feat_event"} <= names) def test_schema_is_idempotent(self): - c = _conn() + c = _conn(self) df.apply_schema(c) # second apply must not raise self.assertTrue(True) def test_feat_ppg_has_burst_index_column(self): # PR#553: the raw per-burst counter column ships on a fresh feat_ppg. - c = _conn() + c = _conn(self) cols = {r[1] for r in c.execute("PRAGMA table_info(feat_ppg)")} self.assertIn("burst_index", cols) @@ -37,6 +39,7 @@ def test_burst_index_migration_is_idempotent_on_legacy_db(self): # A pre-PR#553 DB has feat_ppg WITHOUT burst_index; apply_schema must ALTER it in once, and a # second apply must be a no-op (never raise "duplicate column"). c = sqlite3.connect(":memory:") + self.addCleanup(c.close) c.executescript( "CREATE TABLE feat_ppg (device_id INTEGER NOT NULL, unix INTEGER NOT NULL, " "sample_idx INTEGER NOT NULL, channel INTEGER NOT NULL, value INTEGER NOT NULL, " @@ -67,9 +70,9 @@ def test_drops_nonpositive(self): self.assertEqual(df.rr_stats([0, -5, 600]), (1, 600.0, None)) -def _rec(inner_type, parsed, unix=1000, version=24, crc_ok=True, device_id=1): +def _rec(inner_type, parsed, unix=1000, version=24, ok=True, device_id=1): return {"device_id": device_id, "unix": unix, "inner_type": inner_type, - "version": version, "crc_ok": crc_ok, "parsed": parsed} + "version": version, "ok": ok, "parsed": parsed} class FeatureToRowsTests(unittest.TestCase): @@ -134,10 +137,23 @@ def test_event_numeric(self): self.assertEqual(out["event"]["kind"], "123") self.assertEqual(out["event"]["event_num"], 123) - def test_crc_invalid_skipped(self): - out = df.feature_to_rows(_rec(47, {"heart_rate": 95}, crc_ok=False)) + def test_rejected_frame_skipped(self): + out = df.feature_to_rows(_rec(47, {"heart_rate": 95}, ok=False)) self.assertEqual(out, {"second": None, "rr": [], "ppg": [], "event": None}) + def test_missing_verdict_is_rejected_not_passed(self): + # A record that carries no verdict at all yields no rows: absence is a rejection, never a pass. + rec = _rec(47, {"heart_rate": 95}) + del rec["ok"] + self.assertEqual(df.feature_to_rows(rec), + {"second": None, "rr": [], "ppg": [], "event": None}) + + def test_rejected_event_frame_skipped(self): + # The verdict gates events too, not only the per-second data types. + out = df.feature_to_rows(_rec(48, {"event": "WRIST_ON", "event_timestamp": 1000}, + unix=None, ok=False)) + self.assertIsNone(out["event"]) + def test_no_unix_non_event_skipped(self): out = df.feature_to_rows(_rec(47, {"heart_rate": 95}, unix=None)) self.assertIsNone(out["second"]) @@ -149,7 +165,7 @@ def test_metadata_type_skipped(self): class ApplyRowsTests(unittest.TestCase): def test_inserts_all_tables(self): - c = _conn() + c = _conn(self) mapped = [ df.feature_to_rows(_rec(47, {"heart_rate": 95, "rr_intervals": [600, 610]})), df.feature_to_rows(_rec(47, {"ppg_waveform": [1, 2], "burst_index": 3}, unix=1001, version=26)), @@ -165,7 +181,7 @@ def test_inserts_all_tables(self): self.assertEqual((ch, bi), (0, 3)) def test_idempotent(self): - c = _conn() + c = _conn(self) mapped = [df.feature_to_rows(_rec(47, {"heart_rate": 95, "rr_intervals": [600]}))] df.apply_rows(c, 1, mapped) df.apply_rows(c, 1, mapped) # second time changes nothing @@ -173,7 +189,7 @@ def test_idempotent(self): self.assertEqual(c.execute("SELECT COUNT(*) FROM feat_rr").fetchone()[0], 1) def test_coalesce_merges_partial_seconds(self): - c = _conn() + c = _conn(self) # scalar-only second, then ppg-only at SAME unix must not clobber hr. df.apply_rows(c, 1, [df.feature_to_rows(_rec(47, {"heart_rate": 88}, unix=1000))]) df.apply_rows(c, 1, [df.feature_to_rows(_rec(47, {"gravity_x": 0.5}, unix=1000, version=18))]) @@ -182,9 +198,9 @@ def test_coalesce_merges_partial_seconds(self): self.assertEqual(row[1], 0.5) # merged in -def _decoded(fields, crcOK=True, family="whoop5"): +def _decoded(fields, crcOK=True, family="whoop5", ok=True): return {"family": family, "char": "x", "ts_ms": 0, "hr": None, - "frame": {"crcOK": crcOK, "seq": 1, "fields": fields}} + "frame": {"ok": ok, "crcOK": crcOK, "seq": 1, "fields": fields}} def _fld(name, value, cat): @@ -208,12 +224,13 @@ def test_flattens_fields_dropping_envelope_and_raw(self): self.assertEqual(rec["unix"], 1700000000) self.assertEqual(rec["inner_type"], 47) self.assertEqual(rec["version"], 18) - self.assertTrue(rec["crc_ok"]) + self.assertTrue(rec["ok"]) def test_carries_rr_intervals_from_parsed(self): # The R-R array is parsed-only (per-interval values are rr[i] FIELDS, the array is not a field), # so normalize must carry frame.parsed["rr_intervals"] through or feat_rr/rmssd stay empty. - raw = {"family": "whoop5", "frame": {"crcOK": True, "fields": [_fld("rr_count", 2, "rr")], + raw = {"family": "whoop5", "frame": {"ok": True, "crcOK": True, + "fields": [_fld("rr_count", 2, "rr")], "parsed": {"rr_intervals": [602, 613]}}} src = (1, "aa", "fd4b0005", 0, 1700000000, None, 47) rec = df.normalize_decode_record(raw, src, 1) @@ -222,7 +239,7 @@ def test_carries_rr_intervals_from_parsed(self): def test_realtime_type40_timestamp_supplies_unix_and_rr(self): # Realtime (type-40) frames have no frames.unix; the decoded `timestamp` field supplies it, and # the carried rr_intervals must flow into feat_rr (the realtime-capture path). - raw = {"family": "whoop5", "frame": {"crcOK": True, + raw = {"family": "whoop5", "frame": {"ok": True, "crcOK": True, "fields": [_fld("timestamp", 1781084150, "time"), _fld("rr_count", 1, "rr")], "parsed": {"timestamp": 1781084150, "rr_intervals": [963]}}} src = (1, "aa", "fd4b0005", 1781084150307, None, None, 40) # frames.unix = None @@ -230,10 +247,21 @@ def test_realtime_type40_timestamp_supplies_unix_and_rr(self): self.assertEqual(m["second"]["unix"], 1781084150) self.assertEqual(m["rr"], [{"unix": 1781084150, "idx": 0, "rr_ms": 963}]) - def test_crcOK_false(self): - raw = _decoded([_fld("heart_rate", 90, "hr")], crcOK=False) + def test_verdict_false(self): + raw = _decoded([_fld("heart_rate", 90, "hr")], crcOK=False, ok=False) + src = (1, "aa", "c", 0, 100, None, 47) + self.assertFalse(df.normalize_decode_record(raw, src, 1)["ok"]) + + def test_payload_crc_ok_but_envelope_rejected(self): + # The class this gate is for: the payload CRC32 verifies, but the decoder's full verdict is + # negative (broken header checksum or a length that does not add up). `crcOK` alone would + # have let the frame through. + raw = _decoded([_fld("heart_rate", 90, "hr")], crcOK=True, ok=False) src = (1, "aa", "c", 0, 100, None, 47) - self.assertFalse(df.normalize_decode_record(raw, src, 1)["crc_ok"]) + rec = df.normalize_decode_record(raw, src, 1) + self.assertFalse(rec["ok"]) + self.assertEqual(df.feature_to_rows(rec), + {"second": None, "rr": [], "ppg": [], "event": None}) def test_whoop4_version_defaults_to_24(self): raw = _decoded([_fld("heart_rate", 110, "hr")], family="whoop4") # no hist_version field @@ -254,18 +282,24 @@ def test_unix_falls_back_to_source_when_decoder_lacks_it(self): src = (1, "aa", "c", 0, 555, None, 48) self.assertEqual(df.normalize_decode_record(raw, src, 1)["unix"], 555) - def test_missing_frame_is_empty_parsed_crc_true(self): + def test_missing_frame_is_empty_parsed_and_rejected(self): src = (1, "aa", "c", 0, 100, None, 47) rec = df.normalize_decode_record({}, src, 1) self.assertEqual(rec["parsed"], {}) - self.assertTrue(rec["crc_ok"]) + self.assertFalse(rec["ok"]) self.assertIsNone(rec["version"]) + def test_missing_verdict_field_is_rejected(self): + # An object whose frame carries no `ok` key at all: fail closed, do not assume a pass. + raw = {"family": "whoop5", "frame": {"crcOK": True, "fields": [_fld("heart_rate", 90, "hr")]}} + src = (1, "aa", "c", 0, 100, None, 47) + self.assertFalse(df.normalize_decode_record(raw, src, 1)["ok"]) + -def _dec_obj(fields, family="whoop4", crcOK=True): +def _dec_obj(fields, family="whoop4", crcOK=True, ok=True): """A whoop-decode --json object stub (real shape: values live in frame.fields).""" return {"family": family, "char": "x", "ts_ms": 0, "hr": None, - "frame": {"crcOK": crcOK, "seq": 1, "fields": fields}} + "frame": {"ok": ok, "crcOK": crcOK, "seq": 1, "fields": fields}} class _FakeDB: @@ -286,6 +320,11 @@ def frames_after(self, did, after_id): class DecodeNewTests(unittest.TestCase): + def _db(self): + db = _FakeDB(self._frames()) + self.addCleanup(db.db.close) + return db + def _frames(self): return [ (1, "aa", "6108", 10, 1000, 95, 47), @@ -302,7 +341,7 @@ def _decoder(self, records): ] def test_decodes_and_advances_cursor(self): - fdb = _FakeDB(self._frames()) + fdb = self._db() res = df.decode_new(fdb, 1, decode_fn=self._decoder) self.assertEqual(res["frames"], 3) self.assertEqual(res["skipped"], 1) # the inner_type-49 frame @@ -310,19 +349,33 @@ def test_decodes_and_advances_cursor(self): self.assertEqual(fdb.state(1)["last_decoded_frame_id"], "3") def test_incremental_second_run_is_noop(self): - fdb = _FakeDB(self._frames()) + fdb = self._db() df.decode_new(fdb, 1, decode_fn=self._decoder) res2 = df.decode_new(fdb, 1, decode_fn=self._decoder) self.assertEqual(res2["frames"], 0) # cursor past all frames def test_full_redecodes_from_zero(self): - fdb = _FakeDB(self._frames()) + fdb = self._db() df.decode_new(fdb, 1, decode_fn=self._decoder) res = df.decode_new(fdb, 1, full=True, decode_fn=self._decoder) self.assertEqual(res["frames"], 3) # full ignores the cursor + def test_rejected_frame_contributes_no_rows(self): + # End to end over the whole stage: a frame the decoder rejected (payload CRC fine, envelope + # not) writes nothing and counts as skipped, while its intact neighbour still lands. + fdb = self._db() + decoder = lambda records: [ + _dec_obj([_fld("heart_rate", 95, "hr")], crcOK=True, ok=False), + _dec_obj([_fld("heart_rate", 96, "hr")]), + _dec_obj([_fld("meta_type", 3, "meta")]), + ] + res = df.decode_new(fdb, 1, decode_fn=decoder) + self.assertEqual(res["skipped"], 2) # the rejected frame + the inner_type-49 one + rows = fdb.db.execute("SELECT unix, hr FROM feat_second").fetchall() + self.assertEqual(rows, [(1001, 96)]) + def test_decoder_count_mismatch_raises(self): - fdb = _FakeDB(self._frames()) # 3 frames + fdb = self._db() # 3 frames short = lambda records: [_dec_obj([_fld("heart_rate", 95, "hr")])] # returns only 1 with self.assertRaises(RuntimeError): df.decode_new(fdb, 1, decode_fn=short) diff --git a/Tools/linux-capture/test_validate_spo2_candidate.py b/Tools/linux-capture/test_validate_spo2_candidate.py index 155eaff60d..5e8bcf1113 100644 --- a/Tools/linux-capture/test_validate_spo2_candidate.py +++ b/Tools/linux-capture/test_validate_spo2_candidate.py @@ -6,18 +6,65 @@ from __future__ import annotations +import builtins +import io import json import os import sqlite3 import struct import tempfile import unittest +from unittest import mock from datetime import datetime, timezone import validate_spo2_candidate as vs from test_whoop_activity import make_v18 +class EncodingPortabilityTests(unittest.TestCase): + """Exercise Windows-like non-UTF-8 defaults without requiring a Windows host.""" + + def test_capture_json_is_read_as_utf8_under_cp1252_default(self): + with tempfile.TemporaryDirectory() as td: + path = os.path.join(td, "capture.json") + expected = [{"hex": "aa", "device": "München"}] + with builtins.open(path, "w", encoding="utf-8") as f: + json.dump(expected, f, ensure_ascii=False) + + real_open = builtins.open + + def cp1252_default_open(*args, **kwargs): + mode = args[1] if len(args) > 1 else kwargs.get("mode", "r") + if "b" not in mode and "encoding" not in kwargs: + kwargs["encoding"] = "cp1252" + return real_open(*args, **kwargs) + + with mock.patch("builtins.open", side_effect=cp1252_default_open): + self.assertEqual(vs.load_frame_records(path), expected) + + def test_postable_summary_prints_to_cp1252_console(self): + result = { + "device": "strap-a", + "classification": "feature_absent", + "paired_nights": 0, + "r": None, + "mae": None, + "bias": None, + "best_specificity_offset": None, + "duty": None, + "median_window_coverage": None, + "checklist": {"pass": False}, + } + raw = io.BytesIO() + console = io.TextIOWrapper(raw, encoding="cp1252") + with mock.patch.object(vs.sys, "stdout", console): + vs.configure_utf8_stdio() + print(vs.format_postable([result])) + console.flush() + self.assertEqual(console.encoding, "utf-8") + self.assertIn("need ≥5 paired nights".encode(), raw.getvalue()) + + def _utc(y, m, d, hh=0, mm=0, ss=0) -> int: return int(datetime(y, m, d, hh, mm, ss, tzinfo=timezone.utc).timestamp()) @@ -102,7 +149,7 @@ def _write_export(folder: str, nights: list[tuple[str, float, int, int]]) -> str s0 = datetime.fromtimestamp(t0, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S") s1 = datetime.fromtimestamp(t1, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S") lines.append(f"{start},,{spo2:.2f},{s0},{s1}\n") - with open(path, "w") as f: + with open(path, "w", encoding="utf-8") as f: f.writelines(lines) return folder @@ -136,7 +183,7 @@ def test_recovers_perfect_correlation_across_nights(self): with tempfile.TemporaryDirectory() as td: cap = os.path.join(td, "capture.json") - with open(cap, "w") as f: + with open(cap, "w", encoding="utf-8") as f: json.dump(frames, f) exp = _write_export(td, exports) res = vs.validate_device(cap, exp, device="planted-a") @@ -154,11 +201,11 @@ def test_incomplete_nights_without_export_spo2_are_skipped(self): frames = _plant_night(t0, t1, 97) with tempfile.TemporaryDirectory() as td: cap = os.path.join(td, "capture.json") - with open(cap, "w") as f: + with open(cap, "w", encoding="utf-8") as f: json.dump(frames, f) # Export row with empty SpO₂ — must not pair. path = os.path.join(td, "physiological_cycles.csv") - with open(path, "w") as f: + with open(path, "w", encoding="utf-8") as f: f.write( "Cycle start time,Blood oxygen %,Sleep onset,Wake onset\n" "2026-06-10 23:00:00,," @@ -182,7 +229,7 @@ def test_awake_samples_do_not_contribute(self): rec["hex"] = bytes(f).hex() with tempfile.TemporaryDirectory() as td: cap = os.path.join(td, "capture.json") - with open(cap, "w") as f: + with open(cap, "w", encoding="utf-8") as f: json.dump(frames, f) start = datetime.fromtimestamp(t0, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S") exp = _write_export(td, [(start, 97.0, t0, t1)]) @@ -204,7 +251,7 @@ def test_offset_specificity_prefers_true_offset(self): exports.append((start, float(spo2), t0, t1)) with tempfile.TemporaryDirectory() as td: cap = os.path.join(td, "capture.json") - with open(cap, "w") as f: + with open(cap, "w", encoding="utf-8") as f: json.dump(frames, f) exp = _write_export(td, exports) res = vs.validate_device(cap, exp, device="spec") @@ -222,7 +269,7 @@ def test_postable_summary_has_no_raw_values(self): exports.append((start, float(spo2), t0, t1)) with tempfile.TemporaryDirectory() as td: cap = os.path.join(td, "capture.json") - with open(cap, "w") as f: + with open(cap, "w", encoding="utf-8") as f: json.dump(frames, f) exp = _write_export(td, exports) res = vs.validate_device(cap, exp, device="post") @@ -248,7 +295,7 @@ def make_device(td, label, spo2s): ) exports.append((start, float(spo2), t0, t1)) cap = os.path.join(td, f"{label}.json") - with open(cap, "w") as f: + with open(cap, "w", encoding="utf-8") as f: json.dump(frames, f) exp_dir = os.path.join(td, label) os.makedirs(exp_dir) @@ -259,7 +306,7 @@ def make_device(td, label, spo2s): a = make_device(td, "strap-a", [95, 96, 97, 98, 94, 99]) b = make_device(td, "strap-b", [93, 94, 95, 96, 97, 98]) batch = os.path.join(td, "devices.json") - with open(batch, "w") as f: + with open(batch, "w", encoding="utf-8") as f: json.dump([a, b], f) rc = vs.main(["--batch", batch, "--postable"]) self.assertEqual(rc, 0) @@ -307,7 +354,7 @@ def test_detects_sqlite_by_header_not_extension(self): def test_json_captures_still_load_unchanged(self): path = tempfile.mktemp(suffix=".json") - with open(path, "w") as f: + with open(path, "w", encoding="utf-8") as f: json.dump([{"hex": make_v18(sleep_state=2, aux_byte_82=94).hex()}], f) self.addCleanup(lambda: os.path.exists(path) and os.unlink(path)) self.assertFalse(vs.looks_like_sqlite(path)) @@ -471,7 +518,7 @@ def test_coverage_is_reported_per_night_and_low_nights_are_flagged(self): frames = [f for f in self.frames if _unix_of(f) < self.t0 + 3600] with tempfile.TemporaryDirectory() as td: cap = os.path.join(td, "capture.json") - with open(cap, "w") as fh: + with open(cap, "w", encoding="utf-8") as fh: json.dump(frames, fh) start = datetime.fromtimestamp(self.t0, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S") exp = _write_export(td, [(start, 96.0, self.t0, self.t1)]) @@ -534,7 +581,7 @@ def _device(self, td, *, spo2s, neighbor_offset, neighbor_values, jitter): start = datetime.fromtimestamp(t0, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S") exports.append((start, float(spo2), t0, t1)) cap = os.path.join(td, "capture.json") - with open(cap, "w") as f: + with open(cap, "w", encoding="utf-8") as f: json.dump(frames, f) return cap, _write_export(td, exports) @@ -568,7 +615,7 @@ def test_the_floor_also_gates_82_itself(self): exports.append((start, float(spo2), t0, t1)) with tempfile.TemporaryDirectory() as td: cap = os.path.join(td, "capture.json") - with open(cap, "w") as f: + with open(cap, "w", encoding="utf-8") as f: json.dump(frames, f) res = vs.validate_device(cap, _write_export(td, exports), device="flat82") self.assertEqual(res["inband_distinct_82"], 2) @@ -601,7 +648,7 @@ def _flat_zero_device(self, td, label, *, n_records=851, step_s=5): for i in range(n_records) ] cap = os.path.join(td, f"{label}.json") - with open(cap, "w") as f: + with open(cap, "w", encoding="utf-8") as f: json.dump(frames, f) exp_dir = os.path.join(td, label) os.makedirs(exp_dir) @@ -639,7 +686,7 @@ def passing(td, label, spo2s): start = datetime.fromtimestamp(t0, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S") exports.append((start, float(spo2), t0, t1)) cap = os.path.join(td, f"{label}.json") - with open(cap, "w") as f: + with open(cap, "w", encoding="utf-8") as f: json.dump(frames, f) exp_dir = os.path.join(td, label) os.makedirs(exp_dir) @@ -692,7 +739,7 @@ def test_an_aliased_capture_of_a_WORKING_strap_is_not_reported_absent(self): start = datetime.fromtimestamp(t0, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S") nights.append((start, 95.0, t0, t1)) cap = os.path.join(td, "aliased.json") - with open(cap, "w") as f: + with open(cap, "w", encoding="utf-8") as f: json.dump(frames, f) res = vs.validate_device(cap, _write_export(td, nights), device="aliased") self.assertEqual(res["duty"]["n_nonzero"], 0) # the strap works; the capture missed it @@ -710,7 +757,7 @@ def test_a_dense_burst_plus_one_far_later_record_cannot_claim_absence(self): {"hex": make_v18(unix=u, sleep_state=2, hr=52, aux_byte_82=0).hex()} for u in stamps ] cap = os.path.join(td, "sparse.json") - with open(cap, "w") as f: + with open(cap, "w", encoding="utf-8") as f: json.dump(frames, f) exp_dir = os.path.join(td, "sparse") os.makedirs(exp_dir) @@ -727,7 +774,7 @@ def _asleep_device(self, td, label, stamps): {"hex": make_v18(unix=u, sleep_state=st, hr=52, aux_byte_82=0).hex()} for u, st in stamps ] cap = os.path.join(td, f"{label}.json") - with open(cap, "w") as f: + with open(cap, "w", encoding="utf-8") as f: json.dump(frames, f) exp_dir = os.path.join(td, label) os.makedirs(exp_dir) @@ -789,7 +836,7 @@ def test_postable_still_carries_no_raw_spo2_values(self): start = datetime.fromtimestamp(t0, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S") nights.append((start, float(s), t0, t1)) cap = os.path.join(td, "vals.json") - with open(cap, "w") as f: + with open(cap, "w", encoding="utf-8") as f: json.dump(frames, f) res = vs.validate_device(cap, _write_export(td, nights), device="vals") blob = vs.format_postable([res]) @@ -886,7 +933,7 @@ def test_app_store_is_told_apart_from_a_capture_store(self): app = _write_app_db(td, [(1700000000, 96, 2)]) self.assertTrue(vs.looks_like_app_db(app)) cap = os.path.join(td, "cap.json") - with open(cap, "w") as f: + with open(cap, "w", encoding="utf-8") as f: json.dump([], f) self.assertFalse(vs.looks_like_app_db(cap)) @@ -975,6 +1022,6 @@ def _six_night_capture(self, td: str) -> dict: frames.append({"hex": make_v18(t0 + k * 60, aux_byte_82=int(round(spo2)), sleep_state=vs.SLEEP_ASLEEP).hex()}) cap = os.path.join(td, "cap.json") - with open(cap, "w") as f: + with open(cap, "w", encoding="utf-8") as f: json.dump(frames, f) return {"capture": cap, "export": _write_export(td, nights)} diff --git a/Tools/linux-capture/test_whoop_frame.py b/Tools/linux-capture/test_whoop_frame.py index dfad726f85..4763efbb10 100644 --- a/Tools/linux-capture/test_whoop_frame.py +++ b/Tools/linux-capture/test_whoop_frame.py @@ -160,6 +160,71 @@ def test_build_history_ack_whoop4_shape_and_crc(self): self.assertTrue(wf.verify_whoop4_frame(ack)) # ack is itself CRC-valid +def _whoop4_frame_of_total(total: int) -> bytes: + """A structurally consistent WHOOP 4.0 frame of exactly `total` bytes: the declared length agrees + with the byte count, the CRC8 header checks out and the CRC32 trailer covers the inner bytes that + are left (none at all for the 8-byte case).""" + length = total - 4 + len_bytes = bytes([length & 0xFF, (length >> 8) & 0xFF]) + inner = (bytes([wf.COMMAND_TYPE, 0, wf.CMD_GET_BATTERY_LEVEL]) + bytes(8))[:total - 8] + trailer = wf.crc32(inner).to_bytes(4, "little") + return bytes([0xAA]) + len_bytes + bytes([wf.crc8(len_bytes)]) + inner + trailer + + +def _whoop5_frame_of_total(total: int) -> bytes: + """The WHOOP 5.0/MG image of `_whoop4_frame_of_total`: declared length, CRC16 header and CRC32 + trailer are all internally consistent, so only the size distinguishes the cases.""" + decl = total - 8 + head = bytes([0xAA, 0x01, decl & 0xFF, (decl >> 8) & 0xFF, 0x00, 0x00]) + payload = (bytes([wf.COMMAND_TYPE, 0, 1, 0]) + bytes(8))[:total - 12] + return (head + wf.crc16_modbus(head).to_bytes(4, "little")[:2] + payload + + wf.crc32(payload).to_bytes(4, "little")) + + +class MinimumFrameLengthTests(unittest.TestCase): + """The family minimums from WhoopProtocol's `FrameLimits` (11 bytes on WHOOP 4.0, 13 on 5.0/MG). + + A frame below them is internally consistent — declared length, header checksum and a CRC32 over + zero payload bytes all agree — and was accepted before. It is rejected here so the verifiers of + this tool and of the protocol package answer the same question; the callers on the trim-ack path + already demand more bytes than this, so the effect is on the verifiers' other callers. + """ + + def test_whoop4_eight_byte_frame_is_rejected(self): + frame = _whoop4_frame_of_total(8) + self.assertEqual(len(frame), 8) + self.assertEqual(frame[3], wf.crc8(frame[1:3])) # header checksum is correct + self.assertEqual(frame[4:8], wf.crc32(b"").to_bytes(4, "little")) # CRC32 over no payload + self.assertFalse(wf.verify_whoop4_frame(frame)) + + def test_whoop4_below_minimum_is_rejected(self): + for total in (8, 9, 10): + with self.subTest(total=total): + self.assertFalse(wf.verify_whoop4_frame(_whoop4_frame_of_total(total))) + + def test_whoop4_at_minimum_is_accepted(self): + frame = _whoop4_frame_of_total(11) + self.assertEqual(len(frame), 11) + self.assertTrue(wf.verify_whoop4_frame(frame)) + + def test_whoop5_twelve_byte_frame_is_rejected(self): + frame = _whoop5_frame_of_total(12) + self.assertEqual(len(frame), 12) + self.assertEqual(frame[6] | (frame[7] << 8), wf.crc16_modbus(frame[0:6])) + self.assertEqual(frame[8:12], wf.crc32(b"").to_bytes(4, "little")) + self.assertFalse(wf.verify_whoop5_frame(frame)) + + def test_whoop5_at_minimum_is_accepted(self): + frame = _whoop5_frame_of_total(13) + self.assertEqual(len(frame), 13) + self.assertTrue(wf.verify_whoop5_frame(frame)) + + def test_real_frames_stay_valid(self): + # The recorded frames are far above both bounds; raising the minimum must not touch them. + self.assertTrue(wf.verify_whoop5_frame(HistoryAckTests.HISTORY_END)) + self.assertTrue(wf.verify_whoop4_frame(Whoop4HistoryAckTests.HISTORY_END)) + + class ReassemblerTests(unittest.TestCase): def test_single_frame_across_fragments(self): hello = wf.WHOOP5_CLIENT_HELLO diff --git a/Tools/linux-capture/test_whoop_spot_hrv.py b/Tools/linux-capture/test_whoop_spot_hrv.py index f756558a10..f5b1bf4100 100644 --- a/Tools/linux-capture/test_whoop_spot_hrv.py +++ b/Tools/linux-capture/test_whoop_spot_hrv.py @@ -5,10 +5,99 @@ README documents and the only one in requirements.txt) or pytest. """ import math +import os +import sqlite3 +import sys +import tempfile +from unittest import mock import whoop_spot_hrv as H +def _empty_ppg_db(): + fd, path = tempfile.mkstemp(suffix=".db") + os.close(fd) + con = sqlite3.connect(path) + con.execute( + "CREATE TABLE feat_ppg (device_id INTEGER, unix INTEGER, sample_idx INTEGER, " + "channel INTEGER, value INTEGER)" + ) + con.close() + return path + + +def _assert_connection_closed(con): + try: + con.execute("SELECT 1") + except sqlite3.ProgrammingError: + return + raise AssertionError("SQLite connection remained open") + + +def _run_main_tracking_connection(path, *extra_args): + opened = [] + real_connect = sqlite3.connect + + def tracking_connect(*args, **kwargs): + con = real_connect(*args, **kwargs) + opened.append(con) + return con + + argv = ["whoop_spot_hrv.py", "--db", path, "--device", "1", *extra_args] + with mock.patch.object(H.sqlite3, "connect", side_effect=tracking_connect), \ + mock.patch.object(sys, "argv", argv): + result = H.main() + assert len(opened) == 1 + return result, opened[0] + + +def test_main_closes_database_on_normal_path(): + path = _empty_ppg_db() + try: + result, con = _run_main_tracking_connection(path, "--start", "1", "--end", "2") + assert result == 0 + _assert_connection_closed(con) + finally: + os.remove(path) + + +def test_main_closes_database_on_no_windows_early_return(): + path = _empty_ppg_db() + try: + result, con = _run_main_tracking_connection(path) + assert result == 1 + _assert_connection_closed(con) + finally: + os.remove(path) + + +def test_main_closes_database_when_query_raises(): + path = _empty_ppg_db() + opened = [] + real_connect = sqlite3.connect + + def tracking_connect(*args, **kwargs): + con = real_connect(*args, **kwargs) + opened.append(con) + return con + + argv = ["whoop_spot_hrv.py", "--db", path, "--device", "1"] + try: + with mock.patch.object(H.sqlite3, "connect", side_effect=tracking_connect), \ + mock.patch.object(H, "_covered_windows", side_effect=RuntimeError("query failed")), \ + mock.patch.object(sys, "argv", argv): + try: + H.main() + except RuntimeError as exc: + assert str(exc) == "query failed" + else: + raise AssertionError("expected query failure") + assert len(opened) == 1 + _assert_connection_closed(opened[0]) + finally: + os.remove(path) + + def _synth_ppg(fs, n_beats, ibi_ms, width_s=0.12): """A clean PPG-like waveform: a Gaussian pulse at each beat time given an IBI sequence.""" beat_t = [0.5] diff --git a/Tools/linux-capture/test_whoop_sync.py b/Tools/linux-capture/test_whoop_sync.py index 3b41d52388..f9d6d999d1 100644 --- a/Tools/linux-capture/test_whoop_sync.py +++ b/Tools/linux-capture/test_whoop_sync.py @@ -8,9 +8,15 @@ verified whoop5 offsets minus 4, and match the on-hardware HISTORY_END the strap serves. """ +import asyncio import os +import sqlite3 +import sys import tempfile +import threading import unittest +from contextlib import suppress +from unittest import mock import whoop_frame as wf import whoop_sync as ws @@ -98,6 +104,89 @@ def setUp(self): self.tmp = tempfile.mkdtemp() self.db = ws.WhoopDB(os.path.join(self.tmp, "t.db")) + def tearDown(self): + self.db.close() + + def test_context_manager_closes_on_normal_exit(self): + path = os.path.join(self.tmp, "normal.db") + with ws.WhoopDB(path) as db: + con = db.db + con.execute("SELECT 1") + with self.assertRaises(sqlite3.ProgrammingError): + con.execute("SELECT 1") + + def test_context_manager_closes_on_exception(self): + path = os.path.join(self.tmp, "error.db") + with self.assertRaisesRegex(RuntimeError, "boom"): + with ws.WhoopDB(path) as db: + con = db.db + raise RuntimeError("boom") + with self.assertRaises(sqlite3.ProgrammingError): + con.execute("SELECT 1") + + def test_constructor_closes_if_schema_initialization_fails(self): + opened = [] + real_connect = sqlite3.connect + + def tracking_connect(*args, **kwargs): + con = real_connect(*args, **kwargs) + opened.append(con) + return con + + path = os.path.join(self.tmp, "init-error.db") + with mock.patch.object(ws.sqlite3, "connect", side_effect=tracking_connect), \ + mock.patch.object(ws.decode_features, "apply_schema", side_effect=RuntimeError("boom")): + with self.assertRaisesRegex(RuntimeError, "boom"): + ws.WhoopDB(path) + self.assertEqual(len(opened), 1) + with self.assertRaises(sqlite3.ProgrammingError): + opened[0].execute("SELECT 1") + + def test_database_outlives_cancelled_thread_commit(self): + """A cancelled to_thread await must not close SQLite while its worker still commits.""" + started = threading.Event() + release = threading.Event() + errors = [] + db_path = os.path.join(self.tmp, "cancelled-commit.db") + real_close = ws.WhoopDB.close + + def close_then_release(db): + real_close(db) + release.set() + + async def candidate_run(args, db): + did = db.upsert_device("AA:BB:CC:DD:EE:FF", model="whoop4") + frame = (1, "61080005", 47, 1000, 60, "aa01") + + def delayed_commit(): + started.set() + release.wait(timeout=0.3) + try: + db.commit_chunk(did, [frame], trim=1, complete=False) + except Exception as exc: + errors.append(exc) + + task = asyncio.create_task(asyncio.to_thread(delayed_commit)) + + async def wait_until_started(): + while not started.is_set(): + await asyncio.sleep(0.005) + + await asyncio.wait_for(wait_until_started(), timeout=1.0) + task.cancel() + with suppress(asyncio.CancelledError): + await task + + argv = ["whoop_sync.py", "sync", "--address", "AA:BB:CC:DD:EE:FF", "--db", db_path] + try: + with mock.patch.object(ws, "_run", side_effect=candidate_run), \ + mock.patch.object(ws.WhoopDB, "close", autospec=True, side_effect=close_then_release), \ + mock.patch.object(sys, "argv", argv): + ws.main() + finally: + release.set() + self.assertEqual(errors, []) + def _frame(self, hexstr, t=47, unix=1000, hr=60): return (1, "61080005", t, unix, hr, hexstr) # (recv_ms, char, inner_type, unix, hr, hex) @@ -138,7 +227,7 @@ def test_export_json_roundtrip_and_filter(self): out = os.path.join(self.tmp, "o.json") n = self.db.export_json(did, out, only_type=47) self.assertEqual(n, 1) - with open(out) as f: + with open(out, encoding="utf-8") as f: recs = json.load(f) self.assertEqual(recs[0]["hex"], "aa01") @@ -157,6 +246,9 @@ def setUp(self): self.tmp = tempfile.mkdtemp() self.db = ws.WhoopDB(os.path.join(self.tmp, "t.db")) + def tearDown(self): + self.db.close() + def _frame(self, hexstr, t=47, unix=1000, hr=60): return (1, "61080005", t, unix, hr, hexstr) # (recv_ms, char, inner_type, unix, hr, hex) diff --git a/Tools/linux-capture/validate_spo2_candidate.py b/Tools/linux-capture/validate_spo2_candidate.py index 5a843831d0..2768af0be1 100644 --- a/Tools/linux-capture/validate_spo2_candidate.py +++ b/Tools/linux-capture/validate_spo2_candidate.py @@ -81,6 +81,7 @@ from datetime import datetime, timezone from typing import Dict, Iterable, List, Optional, Sequence, Tuple +from capture_io import configure_utf8_stdio import whoop_activity as wa import whoop_frame as wf @@ -151,6 +152,7 @@ MIN_DISTINCT_INBAND = 5 MIN_INBAND_STDEV = 0.5 + # English + localized cycle headers → canonical keys (mirrors StrandImport HeaderNorm subset). HEADER_ALIASES = { # English (official export; lowercase after norm) @@ -467,7 +469,7 @@ def load_frame_records(path: str, *, device_id: int = 2) -> List[dict]: ) return [{"hex": r[0]} for r in rows] - with open(path) as f: + with open(path, encoding="utf-8") as f: capture = json.load(f) if not isinstance(capture, list): raise SystemExit(f"{path}: expected a JSON list of frame records, or a whoop_sync.py .db") @@ -1151,6 +1153,7 @@ def format_postable(results: Sequence[dict]) -> str: # --- CLI ------------------------------------------------------------------------------------------- def main(argv: Optional[Sequence[str]] = None) -> int: + configure_utf8_stdio() p = argparse.ArgumentParser( description="Validate WHOOP 5/MG v18 spo2_candidate_82 against a CSV export (#103)." ) @@ -1205,7 +1208,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: results: List[dict] = [] if args.batch: - with open(args.batch) as f: + with open(args.batch, encoding="utf-8") as f: batch = json.load(f) if not isinstance(batch, list) or not batch: print("batch file must be a non-empty JSON list", file=sys.stderr) diff --git a/Tools/linux-capture/whoop_activity.py b/Tools/linux-capture/whoop_activity.py index c32caaadd6..f2b7f611ef 100644 --- a/Tools/linux-capture/whoop_activity.py +++ b/Tools/linux-capture/whoop_activity.py @@ -151,26 +151,30 @@ def sleep_state_minutes(recs): import json import datetime as _dt +from capture_io import configure_utf8_stdio + DEFAULT_DB = "captures/whoop4.db" def records(db_path=DEFAULT_DB, device_id=2, start=None, end=None): """Decoded v18 records (time-ordered) from the frames table.""" con = sqlite3.connect(db_path) - q = "SELECT hex FROM frames WHERE device_id=? AND inner_type=47" - args = [device_id] - if start is not None: - q += " AND unix>=?"; args.append(start) - if end is not None: - q += " AND unix=?"; args.append(start) + if end is not None: + q += " AND unix int: if __name__ == "__main__": + configure_utf8_stdio() sys.exit(main()) diff --git a/Tools/linux-capture/whoop_capture.py b/Tools/linux-capture/whoop_capture.py index 976d2d65d5..e48240fe23 100644 --- a/Tools/linux-capture/whoop_capture.py +++ b/Tools/linux-capture/whoop_capture.py @@ -29,6 +29,7 @@ from bleak import BleakClient, BleakScanner +from capture_io import configure_utf8_stdio import whoop_frame as wf # --- GATT UUIDs (from docs/BLE_REVERSE_ENGINEERING.md) --------------------------------------------- @@ -146,7 +147,7 @@ def flush(self): if not self.records: return tmp = self.out_path + ".tmp" - with open(tmp, "w") as f: + with open(tmp, "w", encoding="utf-8") as f: json.dump(self.records, f, indent=1) import os os.replace(tmp, self.out_path) @@ -496,4 +497,5 @@ def main(): if __name__ == "__main__": + configure_utf8_stdio() main() diff --git a/Tools/linux-capture/whoop_frame.py b/Tools/linux-capture/whoop_frame.py index 91d50a2244..a9b40671c1 100644 --- a/Tools/linux-capture/whoop_frame.py +++ b/Tools/linux-capture/whoop_frame.py @@ -263,14 +263,26 @@ def command_response_cmd(frame: bytes, family: str): WHOOP5_META_TRIM_OFF = 21 WHOOP5_END_DATA_LEN = 8 +# Smallest well-formed frame per family, mirroring `FrameLimits` in the Swift WhoopProtocol package +# (whoop4MinimumFrameBytes = 11, whoop5MinimumFrameBytes = 13). Below these, a byte run can still be +# internally consistent while carrying next to no payload — at the smallest size its CRC32 covers no +# payload byte at all — and both verifiers here used to accept it. +# +# On the trim-ack path itself this bound is not reachable: `history_end_data*` already requires 25 and +# 29 bytes before it verifies anything. The bound is here so this tool's verifiers answer the same +# question as the protocol package's, for their other callers. +WHOOP4_MIN_FRAME_BYTES = 11 +WHOOP5_MIN_FRAME_BYTES = 13 + def verify_whoop5_frame(frame: bytes) -> bool: - """True if a WHOOP 5.0 frame's declared length, CRC16 header and CRC32 trailer all check out. + """True if a WHOOP 5.0 frame clears the family minimum and its declared length, CRC16 header and + CRC32 trailer all check out. The ack must only echo a genuine, intact HISTORY_END — CRC is the protocol's only integrity check, so a garbled BLE frame must never advance the strap's trim cursor. """ - if len(frame) < 12 or frame[0] != 0xAA: + if len(frame) < WHOOP5_MIN_FRAME_BYTES or frame[0] != 0xAA: return False decl = frame[2] | (frame[3] << 8) total = decl + 8 @@ -327,12 +339,13 @@ def build_history_ack(end_data: bytes, seq: int = 0) -> bytes: def verify_whoop4_frame(frame: bytes) -> bool: - """True if a WHOOP 4.0 frame's declared length, CRC8 header and CRC32 trailer all check out. + """True if a WHOOP 4.0 frame clears the family minimum and its declared length, CRC8 header and + CRC32 trailer all check out. As on 5.0, the ack must only echo a genuine, intact HISTORY_END — CRC is the protocol's only integrity check, so a garbled BLE frame must never advance the strap's trim cursor. """ - if len(frame) < 8 or frame[0] != 0xAA: + if len(frame) < WHOOP4_MIN_FRAME_BYTES or frame[0] != 0xAA: return False declared = frame[1] | (frame[2] << 8) total = declared + 4 diff --git a/Tools/linux-capture/whoop_probe.py b/Tools/linux-capture/whoop_probe.py index 5c359665de..ed926c17db 100644 --- a/Tools/linux-capture/whoop_probe.py +++ b/Tools/linux-capture/whoop_probe.py @@ -16,6 +16,7 @@ from bleak import BleakClient, BleakScanner +from capture_io import configure_utf8_stdio import whoop_frame as wf WHOOP4 = { @@ -147,4 +148,5 @@ def main(): if __name__ == "__main__": + configure_utf8_stdio() main() diff --git a/Tools/linux-capture/whoop_setclock.py b/Tools/linux-capture/whoop_setclock.py index f8067015fa..d52eb659d5 100644 --- a/Tools/linux-capture/whoop_setclock.py +++ b/Tools/linux-capture/whoop_setclock.py @@ -25,6 +25,7 @@ from bleak import BleakClient, BleakScanner +from capture_io import configure_utf8_stdio import whoop_frame as wf FAMILIES = { @@ -167,4 +168,5 @@ def main(): if __name__ == "__main__": + configure_utf8_stdio() main() diff --git a/Tools/linux-capture/whoop_spot_hrv.py b/Tools/linux-capture/whoop_spot_hrv.py index 8c9bd24b80..e58b3ca5bf 100644 --- a/Tools/linux-capture/whoop_spot_hrv.py +++ b/Tools/linux-capture/whoop_spot_hrv.py @@ -27,6 +27,7 @@ from collections import Counter from statistics import median, pstdev +from capture_io import configure_utf8_stdio # ---------------------------------------------------------------- validated DSP (pure) @@ -159,7 +160,13 @@ def main(): args = ap.parse_args() # Read-only: open the DB in immutable/ro mode so this analysis tool can never write to whoop.db. con = sqlite3.connect(f"file:{args.db}?mode=ro", uri=True) + try: + return _run(args, con) + finally: + con.close() + +def _run(args, con): windows = [(args.start, args.end)] if args.start and args.end else _covered_windows(con, args.device, args.channel) if not windows: print("no PPG-covered windows (need v26 optical bursts in feat_ppg for this device)") @@ -179,4 +186,5 @@ def main(): if __name__ == "__main__": + configure_utf8_stdio() raise SystemExit(main()) diff --git a/Tools/linux-capture/whoop_sync.py b/Tools/linux-capture/whoop_sync.py index 0d163db5d5..eb33b25314 100644 --- a/Tools/linux-capture/whoop_sync.py +++ b/Tools/linux-capture/whoop_sync.py @@ -61,8 +61,9 @@ import sys import time -import whoop_frame as wf import decode_features +import whoop_frame as wf +from capture_io import configure_utf8_stdio # `bleak` is imported lazily inside the BLE functions (run/_acquire) so this module's frame helpers, # Family, and WhoopDB can be imported and unit-tested with no third-party deps (like test_whoop_frame). @@ -202,11 +203,24 @@ class WhoopDB: def __init__(self, path): os.makedirs(os.path.dirname(os.path.abspath(path)) or ".", exist_ok=True) self.db = sqlite3.connect(path, check_same_thread=False) - self.db.executescript(SCHEMA) - decode_features.apply_schema(self.db) - self.db.execute("PRAGMA journal_mode=WAL") - self.db.execute("PRAGMA synchronous=FULL") - self.db.commit() + try: + self.db.executescript(SCHEMA) + decode_features.apply_schema(self.db) + self.db.execute("PRAGMA journal_mode=WAL") + self.db.execute("PRAGMA synchronous=FULL") + self.db.commit() + except BaseException: + self.db.close() + raise + + def close(self): + self.db.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() def upsert_device(self, address, name=None, subject=None, model=None): address = address.upper() @@ -280,7 +294,7 @@ def export_json(self, did, out_path, only_type=None, since=None): recs = [{"hex": h, "char": c, "ts_ms": (rm or 0), "hr": hr} for h, c, rm, hr in rows] os.makedirs(os.path.dirname(os.path.abspath(out_path)) or ".", exist_ok=True) tmp = out_path + ".tmp" - with open(tmp, "w") as f: + with open(tmp, "w", encoding="utf-8") as f: json.dump(recs, f, indent=1) os.replace(tmp, out_path) return len(recs) @@ -681,14 +695,17 @@ async def watchdog(): return outcome["v"], s.committed - start -async def run_realtime(args): +async def run_realtime(args, db): + return await _run_realtime(args, db) + + +async def _run_realtime(args, db): """Capture the live realtime stream (HR + R-R + IMU) into the durable store, reconnecting on drops until --duration elapses, then decode → feat_rr so HRV/RSA can run. The realtime counterpart of the offload `run()`; reuses the same _acquire + MTU-negotiated reconnect loop + WhoopDB.""" from bleak import BleakClient from bleak.exc import BleakError fam = Family(args.model) - db = WhoopDB(args.db) if not args.no_clock_check: await preflight_clock(fam, args) dev = await _acquire(args.address, tries=args.tries) @@ -751,11 +768,14 @@ async def run_realtime(args): print(f" decode skipped ({e}); run `whoop_sync.py decode --db {args.db}`.", flush=True) -async def run(args): +async def run(args, db): + return await _run(args, db) + + +async def _run(args, db): from bleak import BleakClient from bleak.exc import BleakError fam = Family(args.model) - db = WhoopDB(args.db) if not args.no_clock_check: await preflight_clock(fam, args) dev = await _acquire(args.address, tries=args.tries) @@ -781,7 +801,7 @@ async def run(args): s.emit = sys.stdout sys.stdout = sys.stderr # subsequent print() diagnostics → stderr elif emit_target: - s.emit = open(emit_target, "w") + s.emit = open(emit_target, "w", encoding="utf-8") if s.emit is not None: s.emit.write(json.dumps({"_header": {"device_id": did, "address": info[0], "name": info[1], "model": args.model}}) + "\n") @@ -980,48 +1000,55 @@ def main(): if cmd == "sync": try: - asyncio.run(run(args)) + with WhoopDB(args.db) as db: + asyncio.run(run(args, db)) except KeyboardInterrupt: pass elif cmd == "realtime": try: - asyncio.run(run_realtime(args)) + with WhoopDB(args.db) as db: + asyncio.run(run_realtime(args, db)) except KeyboardInterrupt: pass elif cmd == "export": - db = WhoopDB(args.db); did = _need_device(db, args.address) - since = _parse_time(args.since) if args.since else None - print(f"exported {db.export_json(did, args.out, args.only_type, since)} frames → {args.out}") + with WhoopDB(args.db) as db: + did = _need_device(db, args.address) + since = _parse_time(args.since) if args.since else None + print(f"exported {db.export_json(did, args.out, args.only_type, since)} frames → {args.out}") elif cmd == "status": - db = WhoopDB(args.db); did = _need_device(db, args.address) - info = db.device_info(did); st = db.state(did); cov = db.coverage(did) - print(f"db: {args.db}") - print(f" device: {info[0]} name={info[1]!r} subject={info[2]!r} model={info[3]}") - print(f" cursor last_trim={st.get('last_trim','?')} history_complete={st.get('history_complete','0')}") - print(f" coverage: {_fmt(cov[0])} → {_fmt(cov[1])} ({cov[2] or 0} type-47)") - print(f" counts by type: {db.counts(did)}") - labs = db.labels(did) - print(f" labels ({len(labs)}):") - for s_, e_, a_, n_ in labs: - print(f" {a_:12s} {_fmt(s_)} → {_fmt(e_)} {n_ or ''}") + with WhoopDB(args.db) as db: + did = _need_device(db, args.address) + info = db.device_info(did); st = db.state(did); cov = db.coverage(did) + print(f"db: {args.db}") + print(f" device: {info[0]} name={info[1]!r} subject={info[2]!r} model={info[3]}") + print(f" cursor last_trim={st.get('last_trim','?')} history_complete={st.get('history_complete','0')}") + print(f" coverage: {_fmt(cov[0])} → {_fmt(cov[1])} ({cov[2] or 0} type-47)") + print(f" counts by type: {db.counts(did)}") + labs = db.labels(did) + print(f" labels ({len(labs)}):") + for s_, e_, a_, n_ in labs: + print(f" {a_:12s} {_fmt(s_)} → {_fmt(e_)} {n_ or ''}") elif cmd == "devices": - db = WhoopDB(args.db) - print(f"db: {args.db}") - for did, addr, nm, subj, mdl, n47, mn, mx in db.list_devices(): - print(f" [{did}] {addr} name={nm!r} subject={subj!r} model={mdl} " - f"{n47} type-47 {_fmt(mn)}→{_fmt(mx)}") + with WhoopDB(args.db) as db: + print(f"db: {args.db}") + for did, addr, nm, subj, mdl, n47, mn, mx in db.list_devices(): + print(f" [{did}] {addr} name={nm!r} subject={subj!r} model={mdl} " + f"{n47} type-47 {_fmt(mn)}→{_fmt(mx)}") elif cmd == "label": - db = WhoopDB(args.db); did = _need_device(db, args.address) - s_, e_ = _parse_time(args.start), _parse_time(args.end) - db.add_label(did, s_, e_, args.activity, args.note) - print(f"labeled {args.activity}: {_fmt(s_)} → {_fmt(e_)} for device {db.device_info(did)[0]}") + with WhoopDB(args.db) as db: + did = _need_device(db, args.address) + s_, e_ = _parse_time(args.start), _parse_time(args.end) + db.add_label(did, s_, e_, args.activity, args.note) + print(f"labeled {args.activity}: {_fmt(s_)} → {_fmt(e_)} for device {db.device_info(did)[0]}") elif cmd == "decode": - db = WhoopDB(args.db); did = _need_device(db, args.address) - res = decode_features.decode_new(db, did, full=args.full) - print(f"decode: {res['frames']} frames → feat_second updated " - f"({res['decoded']} decoded, {res['skipped']} skipped); " - f"cursor last_decoded_frame_id={res['cursor']}") + with WhoopDB(args.db) as db: + did = _need_device(db, args.address) + res = decode_features.decode_new(db, did, full=args.full) + print(f"decode: {res['frames']} frames → feat_second updated " + f"({res['decoded']} decoded, {res['skipped']} skipped); " + f"cursor last_decoded_frame_id={res['cursor']}") if __name__ == "__main__": + configure_utf8_stdio() main() diff --git a/Tools/parity_cases/device_raw_sources.json b/Tools/parity_cases/device_raw_sources.json new file mode 100644 index 0000000000..4a18baafbb --- /dev/null +++ b/Tools/parity_cases/device_raw_sources.json @@ -0,0 +1,79 @@ +{ + "sourceCases": [ + { + "name": "active wins then paired and archived registered straps keep registry order before canonical", + "activeDeviceId": "whoop-new", + "registeredWhoopIds": ["whoop-archived", "my-whoop", "whoop-paired", "whoop-new"], + "imported": ["whoop-new", "whoop-archived", "whoop-paired", "my-whoop"], + "computed": ["whoop-new-noop", "whoop-archived-noop", "whoop-paired-noop", "my-whoop-noop"] + }, + { + "name": "legacy source collapses without duplicate reads", + "activeDeviceId": "my-whoop", + "registeredWhoopIds": ["my-whoop"], + "imported": ["my-whoop"], + "computed": ["my-whoop-noop"] + }, + { + "name": "explicit non-WHOOP active stays first before archived and paired WHOOP history", + "activeDeviceId": "polar-h10-live", + "registeredWhoopIds": ["whoop-archived", "my-whoop", "whoop-paired", "my-whoop"], + "imported": ["polar-h10-live", "whoop-archived", "whoop-paired", "my-whoop"], + "computed": ["polar-h10-live-noop", "whoop-archived-noop", "whoop-paired-noop", "my-whoop-noop"] + } + ], + "rrCases": [ + { + "name": "exact identity tie keeps active while same timestamp distinct beats survive", + "sources": [ + { + "id": "whoop-new", + "beats": [ + {"ts": 102, "rrMs": 810, "seq": 0}, + {"ts": 100, "rrMs": 800, "seq": 0, "ord": 10}, + {"ts": 100, "rrMs": 810, "seq": 1} + ] + }, + { + "id": "my-whoop", + "beats": [ + {"ts": 99, "rrMs": 790, "seq": 0}, + {"ts": 100, "rrMs": 800, "seq": 0, "ord": 20}, + {"ts": 101, "rrMs": 805, "seq": 0} + ] + } + ], + "expected": [ + {"source": "my-whoop", "ts": 99, "rrMs": 790, "seq": 0}, + {"source": "whoop-new", "ts": 100, "rrMs": 800, "seq": 0, "ord": 10}, + {"source": "whoop-new", "ts": 100, "rrMs": 810, "seq": 1}, + {"source": "my-whoop", "ts": 101, "rrMs": 805, "seq": 0}, + {"source": "whoop-new", "ts": 102, "rrMs": 810, "seq": 0} + ] + }, + { + "name": "same timestamp orders by seq then rrMs before conflicting and nil ord", + "sources": [ + { + "id": "whoop-new", + "beats": [ + {"ts": 200, "rrMs": 700, "seq": 1}, + {"ts": 200, "rrMs": 900, "seq": 0, "ord": 1}, + {"ts": 200, "rrMs": 850, "seq": 0, "ord": 99}, + {"ts": 200, "rrMs": 600, "seq": 2, "ord": 0} + ] + }, + { + "id": "my-whoop", + "beats": [] + } + ], + "expected": [ + {"source": "whoop-new", "ts": 200, "rrMs": 850, "seq": 0, "ord": 99}, + {"source": "whoop-new", "ts": 200, "rrMs": 900, "seq": 0, "ord": 1}, + {"source": "whoop-new", "ts": 200, "rrMs": 700, "seq": 1}, + {"source": "whoop-new", "ts": 200, "rrMs": 600, "seq": 2, "ord": 0} + ] + } + ] +} diff --git a/Tools/test_home_i18n.py b/Tools/test_home_i18n.py index ce2da56f11..cf25311dd1 100644 --- a/Tools/test_home_i18n.py +++ b/Tools/test_home_i18n.py @@ -96,6 +96,7 @@ "today.keyMetricsWindowDays", ",", # Stable DashboardCard raw values + preference; units remain measurement metadata. "stress", "fitnessAge", "vo2max", "vitality", "skinTemp", "sleep", "hydration", "coupled", + "coach", "today.dashboardCards", "yrs", "kcal", "", } @@ -505,7 +506,9 @@ def test_apple_home_semantic_display_contracts(self) -> None: self.assertNotIn('result.copy.contains("numbers agree")', (ROOT / "Strand/Screens/SkinTempCardsView.swift").read_text(encoding="utf-8")) self.assertIn('String(localized: "RHR +\\(delta)")', app_model) self.assertIn('String(localized: "HRV −\\(percent)%")', app_model) - self.assertIn('String(localized: "Skin temperature +\\(temperature) °C")', app_model) + # 240c48ae (#1671): the label now carries the reader's unit via UnitFormatter.skinTempSignalPhrase, + # so the sign and the hardcoded °C moved out of the localized literal. + self.assertIn('String(localized: "Skin temperature \\(temperature)")', app_model) self.assertIn('String(localized: "Respiration up")', app_model) self.assertIn("public enum Evidence", readiness) @@ -521,7 +524,7 @@ def test_apple_home_semantic_display_contracts(self) -> None: strings = audit.load_catalog(ROOT / "Strand/Resources/Localizable.xcstrings")["strings"] for key in ( - "RHR +%lld", "HRV −%lld%%", "Skin temperature +%@ °C", "Respiration up", + "RHR +%lld", "HRV −%lld%%", "Skin temperature %@", "Respiration up", "%@ vs %@ %@", "7d %@ / 28d %@", "monotony %@", ): self.assertIn(key, strings) diff --git a/altstore-source.json b/altstore-source.json index b581bd3a28..f16c7334cc 100644 --- a/altstore-source.json +++ b/altstore-source.json @@ -19,6 +19,24 @@ "category": "healthcare-fitness", "screenshotURLs": [], "versions": [ + { + "version": "11.1.0", + "buildVersion": "299", + "date": "2026-09-02", + "localizedDescription": "NOOP 11.1.0. See the release notes for what changed.", + "downloadURL": "https://github.com/ryanbr/noop/releases/download/v11.1.0/NOOP-ios-unsigned-v11.1.0.ipa", + "size": 19387496, + "minOSVersion": "17.0" + }, + { + "version": "11.0.0", + "buildVersion": "292", + "date": "2026-09-01", + "localizedDescription": "NOOP 11.0.0. See the release notes for what changed.", + "downloadURL": "https://github.com/ryanbr/noop/releases/download/v11.0.0/NOOP-ios-unsigned-v11.0.0.ipa", + "size": 19362325, + "minOSVersion": "17.0" + }, { "version": "10.6.0", "buildVersion": "241", @@ -1393,12 +1411,12 @@ "NSMotionUsageDescription": "NOOP reads your iPhone's step count for a walking or running workout to show steps in the activity summary. It stays on your device. Nothing leaves it." } }, - "version": "10.6.0", - "buildVersion": "241", - "versionDate": "2026-08-25", - "versionDescription": "NOOP 10.6.0. See the release notes for what changed.", - "downloadURL": "https://github.com/ryanbr/noop/releases/download/v10.6.0/NOOP-ios-unsigned-v10.6.0.ipa", - "size": 19019520, + "version": "11.1.0", + "buildVersion": "299", + "versionDate": "2026-09-02", + "versionDescription": "NOOP 11.1.0. See the release notes for what changed.", + "downloadURL": "https://github.com/ryanbr/noop/releases/download/v11.1.0/NOOP-ios-unsigned-v11.1.0.ipa", + "size": 19387496, "minOSVersion": "17.0" } ], diff --git a/android/README.md b/android/README.md index baee66a92b..c0a7e48337 100644 --- a/android/README.md +++ b/android/README.md @@ -3,7 +3,9 @@ An **offline WHOOP companion** for Android. NOOP connects directly to a WHOOP 4.0 (and WHOOP 5.0) strap over Bluetooth Low Energy, reads heart rate, R-R intervals, battery, and sensor data, and stores everything **locally** on the device. There is -no account, no server, and no `INTERNET` permission — nothing leaves the phone. +no NOOP account, hosted server, or telemetry. Network access exists only for explicit, +default-off features such as the AI Coach and Experimental one-way push to an endpoint +the user owns. This is an independent Kotlin / Jetpack Compose reimplementation of the macOS/iOS reference app (`Strand/`, Swift). The protocol, framing, and BLE handshake are @@ -189,7 +191,12 @@ install side-by-side. ## Permissions & why -NOOP requests only what BLE needs, and deliberately omits `INTERNET`: +NOOP requests the platform permissions its local features need. `INTERNET` supports only +explicit, default-off network features (the AI Coach and Experimental self-hosted push): + +- **`INTERNET`** — lets those opt-in clients reach the endpoint the user selected. It is an + install-time capability, not evidence of background telemetry; neither feature runs until + configured. - **`BLUETOOTH_SCAN`** (`neverForLocation`) + **`BLUETOOTH_CONNECT`** — Android 12+ (API 31+) runtime permissions. `neverForLocation` lets us skip the location grant on modern Android @@ -257,8 +264,8 @@ phone **and** a WHOOP strap. **Privacy sanity check:** -- [ ] Confirm the merged manifest contains **no `INTERNET` permission** - (`app/build/intermediates/merged_manifests/…`) — the app must stay fully offline. +- [ ] With AI Coach and self-hosted push unconfigured, confirm no HTTP request is made during + launch, BLE collection, offload, analytics, or normal navigation. --- diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 21857254e3..30bafcf48d 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -26,8 +26,8 @@ android { applicationId = "com.noop.whoop" minSdk = 26 targetSdk = 34 - versionCode = 373 - versionName = "10.6.1" + versionCode = 459 + versionName = "11.1.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -160,6 +160,16 @@ val roomSchemaDir = layout.buildDirectory.dir("generated/roomSchemas") ksp { arg("room.schemaLocation", roomSchemaDir.get().asFile.absolutePath) } +// Every KSP round for a Kotlin source set. Both users below start here and then diverge on the +// UnitTest/AndroidTest rounds — in OPPOSITE directions, which is why this half is named rather than +// spelled out twice: +// - [isMainSourceSetKspTask] EXCLUDES them, so Gradle is not told they output a directory they never +// write and then clears it out from under the round that did. +// - `syncRoomSchemaSnapshot`'s `mustRunAfter` INCLUDES them, because they still race the Sync (#1711). +// Two near-identical predicates meaning opposite things is a trap; sharing the common half makes the +// difference the thing the reader sees. +fun isKspKotlinTask(name: String) = name.startsWith("ksp") && name.endsWith("Kotlin") + // Room writes the export as a SIDE EFFECT of annotation processing, at a path Gradle knows nothing // about. Left undeclared, a KSP task that is UP-TO-DATE or restored FROM-CACHE leaves whatever the last // real execution wrote — so an entity edit that is later reverted keeps the stale export on disk and the @@ -178,8 +188,7 @@ ksp { // wiped the export that `kspFullDebugKotlin` had just restored, and the test failed on a clean build with // a warm cache with the directory missing entirely. fun isMainSourceSetKspTask(name: String) = - name.startsWith("ksp") && name.endsWith("Kotlin") && - !name.contains("UnitTest") && !name.contains("AndroidTest") + isKspKotlinTask(name) && !name.contains("UnitTest") && !name.contains("AndroidTest") tasks.matching { isMainSourceSetKspTask(it.name) }.configureEach { outputs.dir(roomSchemaDir).withPropertyName("roomSchemaExport") @@ -208,8 +217,17 @@ val syncRoomSchemaSnapshot = tasks.register("syncRoomSchemaSnapshot") { // variant's KSP round writes byte-identical JSON. Depending on all of them would make a single // `testFullDebugUnitTest` run four KSP rounds instead of one. dependsOn(tasks.matching { it.name == "kspFullDebugKotlin" }) - // Unit-test KSP can clear the snapshot after Sync has copied it; order Sync after those tasks. - mustRunAfter(tasks.matching { it.name.startsWith("ksp") && it.name.contains("UnitTest") }) + // Every KSP round writes `roomSchemaDir` — `room.schemaLocation` is set once, outside any variant + // block — so EVERY one of them is both a producer Sync must not race and a producer Gradle expects + // a declared relationship with. This was scoped to the UnitTest rounds, which covered the observed + // hazard (unit-test KSP clearing the snapshot after Sync copied it) but left `kspFullReleaseKotlin` + // out. It is normally absent from the graph; pull it in — `lintVitalFullRelease` in the same + // invocation as the debug test tasks does — and Gradle fails the build for an undeclared + // producer/consumer pair, with an error that reads like a Room problem (#1711). + // + // Ordering only, so nothing is forced to execute and the one-variant `dependsOn` above keeps its + // point: a `testFullDebugUnitTest` run still triggers exactly one KSP round. + mustRunAfter(tasks.matching { isKspKotlinTask(it.name) }) } tasks.withType().configureEach { @@ -280,6 +298,7 @@ dependencies { testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") testImplementation("org.json:json:20240303") // real org.json for JVM unit tests (android.jar ships throwing stubs) testImplementation("net.sf.kxml:kxml2:2.3.0") // real XmlPullParser for JVM tests (android.util.Xml is a throwing stub) + testImplementation("org.xerial:sqlite-jdbc:3.41.2.2") // execute DAO aggregate SQL in plain-JVM tests androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") androidTestImplementation("androidx.compose.ui:ui-test-junit4") diff --git a/android/app/gradle.lockfile b/android/app/gradle.lockfile index ef15d72d51..b60ae7c042 100644 --- a/android/app/gradle.lockfile +++ b/android/app/gradle.lockfile @@ -283,5 +283,5 @@ org.json:json:20240303=demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntim org.ow2.asm:asm-commons:9.7=demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata org.ow2.asm:asm-tree:9.7=demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata org.ow2.asm:asm:9.7=demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata -org.xerial:sqlite-jdbc:3.41.2.2=_agp_internal_javaPreCompileDemoDebug_kspClasspath,_agp_internal_javaPreCompileDemoRelease_kspClasspath,_agp_internal_javaPreCompileFullDebug_kspClasspath,_agp_internal_javaPreCompileFullRelease_kspClasspath,kspDemoDebugAndroidTestKotlinProcessorClasspath,kspDemoDebugKotlinProcessorClasspath,kspDemoDebugUnitTestKotlinProcessorClasspath,kspDemoReleaseKotlinProcessorClasspath,kspDemoReleaseUnitTestKotlinProcessorClasspath,kspFullDebugAndroidTestKotlinProcessorClasspath,kspFullDebugKotlinProcessorClasspath,kspFullDebugUnitTestKotlinProcessorClasspath,kspFullReleaseKotlinProcessorClasspath,kspFullReleaseUnitTestKotlinProcessorClasspath +org.xerial:sqlite-jdbc:3.41.2.2=_agp_internal_javaPreCompileDemoDebug_kspClasspath,_agp_internal_javaPreCompileDemoRelease_kspClasspath,_agp_internal_javaPreCompileFullDebug_kspClasspath,_agp_internal_javaPreCompileFullRelease_kspClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,kspDemoDebugAndroidTestKotlinProcessorClasspath,kspDemoDebugKotlinProcessorClasspath,kspDemoDebugUnitTestKotlinProcessorClasspath,kspDemoReleaseKotlinProcessorClasspath,kspDemoReleaseUnitTestKotlinProcessorClasspath,kspFullDebugAndroidTestKotlinProcessorClasspath,kspFullDebugKotlinProcessorClasspath,kspFullDebugUnitTestKotlinProcessorClasspath,kspFullReleaseKotlinProcessorClasspath,kspFullReleaseUnitTestKotlinProcessorClasspath empty=_agp_internal_javaPreCompileDemoDebugAndroidTest_kspClasspath,_agp_internal_javaPreCompileDemoDebugUnitTest_kspClasspath,_agp_internal_javaPreCompileDemoReleaseUnitTest_kspClasspath,_agp_internal_javaPreCompileFullDebugAndroidTest_kspClasspath,_agp_internal_javaPreCompileFullDebugUnitTest_kspClasspath,_agp_internal_javaPreCompileFullReleaseUnitTest_kspClasspath,androidApis,androidJdkImage,androidTestApiDependenciesMetadata,androidTestCompileOnlyDependenciesMetadata,androidTestDebugApiDependenciesMetadata,androidTestDebugCompileOnlyDependenciesMetadata,androidTestDebugImplementationDependenciesMetadata,androidTestDebugIntransitiveDependenciesMetadata,androidTestDemoApiDependenciesMetadata,androidTestDemoCompileOnlyDependenciesMetadata,androidTestDemoDebugApiDependenciesMetadata,androidTestDemoDebugCompileOnlyDependenciesMetadata,androidTestDemoDebugImplementationDependenciesMetadata,androidTestDemoDebugIntransitiveDependenciesMetadata,androidTestDemoImplementationDependenciesMetadata,androidTestDemoIntransitiveDependenciesMetadata,androidTestFullApiDependenciesMetadata,androidTestFullCompileOnlyDependenciesMetadata,androidTestFullDebugApiDependenciesMetadata,androidTestFullDebugCompileOnlyDependenciesMetadata,androidTestFullDebugImplementationDependenciesMetadata,androidTestFullDebugIntransitiveDependenciesMetadata,androidTestFullImplementationDependenciesMetadata,androidTestFullIntransitiveDependenciesMetadata,androidTestIntransitiveDependenciesMetadata,androidTestReleaseApiDependenciesMetadata,androidTestReleaseCompileOnlyDependenciesMetadata,androidTestReleaseImplementationDependenciesMetadata,androidTestReleaseIntransitiveDependenciesMetadata,androidTestUtil,compileOnlyDependenciesMetadata,coreLibraryDesugaring,debugApiDependenciesMetadata,debugCompileOnlyDependenciesMetadata,debugIntransitiveDependenciesMetadata,demoApiDependenciesMetadata,demoCompileOnlyDependenciesMetadata,demoDebugAndroidTestAnnotationProcessorClasspath,demoDebugAndroidTestApiDependenciesMetadata,demoDebugAndroidTestCompileOnlyDependenciesMetadata,demoDebugAndroidTestImplementationDependenciesMetadata,demoDebugAndroidTestIntransitiveDependenciesMetadata,demoDebugAnnotationProcessorClasspath,demoDebugApiDependenciesMetadata,demoDebugCompileOnlyDependenciesMetadata,demoDebugImplementationDependenciesMetadata,demoDebugIntransitiveDependenciesMetadata,demoDebugReverseMetadataValues,demoDebugUnitTestAnnotationProcessorClasspath,demoDebugUnitTestApiDependenciesMetadata,demoDebugUnitTestCompileOnlyDependenciesMetadata,demoDebugUnitTestImplementationDependenciesMetadata,demoDebugUnitTestIntransitiveDependenciesMetadata,demoDebugWearBundling,demoImplementationDependenciesMetadata,demoIntransitiveDependenciesMetadata,demoReleaseAnnotationProcessorClasspath,demoReleaseApiDependenciesMetadata,demoReleaseCompileOnlyDependenciesMetadata,demoReleaseImplementationDependenciesMetadata,demoReleaseIntransitiveDependenciesMetadata,demoReleaseReverseMetadataValues,demoReleaseUnitTestAnnotationProcessorClasspath,demoReleaseUnitTestApiDependenciesMetadata,demoReleaseUnitTestCompileOnlyDependenciesMetadata,demoReleaseUnitTestImplementationDependenciesMetadata,demoReleaseUnitTestIntransitiveDependenciesMetadata,demoReleaseWearBundling,fullApiDependenciesMetadata,fullCompileOnlyDependenciesMetadata,fullDebugAndroidTestAnnotationProcessorClasspath,fullDebugAndroidTestApiDependenciesMetadata,fullDebugAndroidTestCompileOnlyDependenciesMetadata,fullDebugAndroidTestImplementationDependenciesMetadata,fullDebugAndroidTestIntransitiveDependenciesMetadata,fullDebugAnnotationProcessorClasspath,fullDebugApiDependenciesMetadata,fullDebugCompileOnlyDependenciesMetadata,fullDebugImplementationDependenciesMetadata,fullDebugIntransitiveDependenciesMetadata,fullDebugReverseMetadataValues,fullDebugUnitTestAnnotationProcessorClasspath,fullDebugUnitTestApiDependenciesMetadata,fullDebugUnitTestCompileOnlyDependenciesMetadata,fullDebugUnitTestImplementationDependenciesMetadata,fullDebugUnitTestIntransitiveDependenciesMetadata,fullDebugWearBundling,fullImplementationDependenciesMetadata,fullIntransitiveDependenciesMetadata,fullReleaseAnnotationProcessorClasspath,fullReleaseApiDependenciesMetadata,fullReleaseCompileOnlyDependenciesMetadata,fullReleaseImplementationDependenciesMetadata,fullReleaseIntransitiveDependenciesMetadata,fullReleaseReverseMetadataValues,fullReleaseUnitTestAnnotationProcessorClasspath,fullReleaseUnitTestApiDependenciesMetadata,fullReleaseUnitTestCompileOnlyDependenciesMetadata,fullReleaseUnitTestImplementationDependenciesMetadata,fullReleaseUnitTestIntransitiveDependenciesMetadata,fullReleaseWearBundling,intransitiveDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinNativeCompilerPluginClasspath,lintChecks,lintPublish,releaseApiDependenciesMetadata,releaseCompileOnlyDependenciesMetadata,releaseImplementationDependenciesMetadata,releaseIntransitiveDependenciesMetadata,testApiDependenciesMetadata,testCompileOnlyDependenciesMetadata,testDebugApiDependenciesMetadata,testDebugCompileOnlyDependenciesMetadata,testDebugImplementationDependenciesMetadata,testDebugIntransitiveDependenciesMetadata,testDemoApiDependenciesMetadata,testDemoCompileOnlyDependenciesMetadata,testDemoDebugApiDependenciesMetadata,testDemoDebugCompileOnlyDependenciesMetadata,testDemoDebugImplementationDependenciesMetadata,testDemoDebugIntransitiveDependenciesMetadata,testDemoImplementationDependenciesMetadata,testDemoIntransitiveDependenciesMetadata,testDemoReleaseApiDependenciesMetadata,testDemoReleaseCompileOnlyDependenciesMetadata,testDemoReleaseImplementationDependenciesMetadata,testDemoReleaseIntransitiveDependenciesMetadata,testFixturesApiDependenciesMetadata,testFixturesCompileOnlyDependenciesMetadata,testFixturesDebugApiDependenciesMetadata,testFixturesDebugCompileOnlyDependenciesMetadata,testFixturesDebugImplementationDependenciesMetadata,testFixturesDebugIntransitiveDependenciesMetadata,testFixturesDemoApiDependenciesMetadata,testFixturesDemoCompileOnlyDependenciesMetadata,testFixturesDemoImplementationDependenciesMetadata,testFixturesDemoIntransitiveDependenciesMetadata,testFixturesFullApiDependenciesMetadata,testFixturesFullCompileOnlyDependenciesMetadata,testFixturesFullImplementationDependenciesMetadata,testFixturesFullIntransitiveDependenciesMetadata,testFixturesImplementationDependenciesMetadata,testFixturesIntransitiveDependenciesMetadata,testFixturesReleaseApiDependenciesMetadata,testFixturesReleaseCompileOnlyDependenciesMetadata,testFixturesReleaseImplementationDependenciesMetadata,testFixturesReleaseIntransitiveDependenciesMetadata,testFullApiDependenciesMetadata,testFullCompileOnlyDependenciesMetadata,testFullDebugApiDependenciesMetadata,testFullDebugCompileOnlyDependenciesMetadata,testFullDebugImplementationDependenciesMetadata,testFullDebugIntransitiveDependenciesMetadata,testFullImplementationDependenciesMetadata,testFullIntransitiveDependenciesMetadata,testFullReleaseApiDependenciesMetadata,testFullReleaseCompileOnlyDependenciesMetadata,testFullReleaseImplementationDependenciesMetadata,testFullReleaseIntransitiveDependenciesMetadata,testIntransitiveDependenciesMetadata,testReleaseApiDependenciesMetadata,testReleaseCompileOnlyDependenciesMetadata,testReleaseImplementationDependenciesMetadata,testReleaseIntransitiveDependenciesMetadata diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6a1734b023..3ce804c414 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,10 +3,10 @@ xmlns:tools="http://schemas.android.com/tools"> @@ -61,6 +61,14 @@ request READ_CALL_LOG and never reads incoming numbers, contacts, or call history. --> + + + + + + @@ -277,6 +291,34 @@ android:resource="@xml/noop_compact_widget_info" /> + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/noop/CrashCapture.kt b/android/app/src/main/java/com/noop/CrashCapture.kt index 5c6b2231df..6924b11967 100644 --- a/android/app/src/main/java/com/noop/CrashCapture.kt +++ b/android/app/src/main/java/com/noop/CrashCapture.kt @@ -1,9 +1,11 @@ package com.noop import android.content.Context +import android.os.Build import java.io.File import java.io.PrintWriter import java.io.StringWriter +import java.security.MessageDigest /** * Captures the last uncaught exception to a file so a crash that only reproduces on a user's own @@ -14,6 +16,8 @@ import java.io.StringWriter */ object CrashCapture { private const val FILE = "last_crash.txt" + private const val PREFS = "noop_crash_capture" + private const val ACKNOWLEDGED = "acknowledged_fingerprint" fun install(context: Context) { val appContext = context.applicationContext @@ -24,8 +28,17 @@ object CrashCapture { val sw = StringWriter() throwable.printStackTrace(PrintWriter(sw)) val text = buildString { - appendLine("when: ${java.util.Date()}") - appendLine("thread: ${thread.name}") + append(crashHeader( + whenText = java.util.Date().toString(), + threadName = thread.name, + appVersion = BuildConfig.VERSION_NAME, + versionCode = BuildConfig.VERSION_CODE, + packageName = appContext.packageName, + androidRelease = Build.VERSION.RELEASE, + sdk = Build.VERSION.SDK_INT, + manufacturer = Build.MANUFACTURER, + model = Build.MODEL, + )) appendLine(sw.toString()) } File(appContext.filesDir, FILE).writeText(text) @@ -40,4 +53,84 @@ object CrashCapture { if (!f.exists()) return null return runCatching { f.readText() }.getOrNull()?.ifBlank { null } } + + /** + * A crash not yet dismissed by the user, shown before launch touches the database or BLE stack. + * + * TOTAL, like [lastCrash] beside it: this is the FIRST thing `MainActivity.onCreate` runs, so a + * throw here does not degrade the recovery screen — it stops the app starting at all, on every + * launch, with no screen left to explain why. A crash-recovery feature that bricks startup is a + * worse failure than the crash it exists to report, so any failure reading the acknowledgement + * yields null and normal startup proceeds. + */ + fun pendingCrash(context: Context): String? = runCatching { + val app = context.applicationContext + val crash = lastCrash(context) ?: return@runCatching null + // A crash from a build the user has already replaced is not news. The capture handler has written + // this file since June while the screen that surfaces it landed in August, so on the first launch + // after upgrading, every install carrying an old crash would open on "NOOP stopped unexpectedly" + // for something survived weeks earlier — which is how this was found: a 53-day-old crash greeted a + // staging build that had not crashed at all. + val lastUpdate = runCatching { + app.packageManager.getPackageInfo(app.packageName, 0).lastUpdateTime + }.getOrDefault(0L) + if (isPreUpgradeCrash(File(app.filesDir, FILE).lastModified(), lastUpdate)) return@runCatching null + val acknowledged = app.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(ACKNOWLEDGED, null) + crash.takeIf { isPending(fingerprint(it), acknowledged) } + }.getOrNull() + + /** + * Keep the crash for diagnostics, but allow the next launch attempt to continue. + * + * `commit()` rather than `apply()`: the caller recreates the Activity immediately, and a process + * death between the two would lose an asynchronous write and put the same crash back in front of + * the user. The write is one small string on a path taken once per crash, so paying for durability + * here costs nothing measurable and removes the only way this can repeat itself. + */ + fun acknowledge(context: Context, crash: String) { + runCatching { + context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putString(ACKNOWLEDGED, fingerprint(crash)).commit() + } + } + + /** + * Whether a stored crash belongs to a build the user is no longer running. + * + * `lastUpdateTime` is when this APK was installed or updated, so a crash file older than it was + * written by a version that has since been replaced. The file is NOT deleted — the strap log and the + * test bundle still carry it, which is exactly how the long-standing R8 minification crash trace was + * recovered — it simply stops opening the app on a recovery screen. + * + * Fails toward SHOWING. If either timestamp is unknown (0), this returns false and the crash surfaces + * as before: the screen exists to report crashes, so an occasional stale one is a smaller failure than + * silently swallowing a real one. The acknowledgement fingerprint still caps that at a single screen. + */ + internal fun isPreUpgradeCrash(crashModifiedMs: Long, lastUpdateMs: Long): Boolean = + crashModifiedMs > 0L && lastUpdateMs > 0L && crashModifiedMs < lastUpdateMs + + internal fun isPending(fingerprint: String, acknowledged: String?) = fingerprint != acknowledged + + internal fun fingerprint(text: String): String = MessageDigest.getInstance("SHA-256") + .digest(text.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + + internal fun crashHeader( + whenText: String, + threadName: String, + appVersion: String, + versionCode: Int, + packageName: String, + androidRelease: String, + sdk: Int, + manufacturer: String, + model: String, + ) = buildString { + appendLine("when: $whenText") + appendLine("app: $appVersion ($versionCode) · $packageName") + appendLine("os: Android $androidRelease (API $sdk)") + appendLine("device: $manufacturer $model") + appendLine("thread: $threadName") + } } diff --git a/android/app/src/main/java/com/noop/NoopApplication.kt b/android/app/src/main/java/com/noop/NoopApplication.kt index e253344f28..3345cb9634 100644 --- a/android/app/src/main/java/com/noop/NoopApplication.kt +++ b/android/app/src/main/java/com/noop/NoopApplication.kt @@ -6,6 +6,7 @@ import androidx.annotation.PluralsRes import androidx.annotation.StringRes import android.util.Log import com.noop.ble.SourceCoordinator +import com.noop.ble.SourceIdentity import com.noop.ble.WhoopBleClient import com.noop.ble.WhoopModel import com.noop.data.DeviceRegistry @@ -13,13 +14,15 @@ import com.noop.data.WhoopDatabase import com.noop.data.WhoopRepository import com.noop.ui.NoopPrefs import com.noop.ui.AppLanguagePrefs +import com.noop.push.SelfHostedPushScheduler import kotlinx.coroutines.runBlocking /** * Application entry point. * * NOOP is a fully on-device WHOOP companion: it connects to the strap over BLE and persists - * everything locally via Room. There is no network layer (the opt-in AI Coach aside). + * everything locally via Room. Network access is opt-in: the AI Coach and the experimental, + * one-way self-hosted push are both disabled by default. * * The data layer ([WhoopRepository]) and the BLE client ([WhoopBleClient]) are owned **here**, at the * process level, rather than by the Activity-scoped AppViewModel. That is what lets a connection keep @@ -41,12 +44,12 @@ class NoopApplication : Application() { override fun onCreate() { super.onCreate() + // Install before any app-owned startup work so even an early failure is preserved for the + // recovery screen on the next launch. + CrashCapture.install(this) // #1008: pin the pre-change Overnight-only default for existing installs before anything // reads it. Idempotent; a no-op on fresh installs and on every launch after the first. com.noop.ui.NoopPrefs.migrateContinuousHrvOvernightDefault(this) - // Record any uncaught crash to a file so it rides along in the shareable strap log — a - // device-specific crash (e.g. Insights #224/#267) is otherwise lost to an unreachable logcat. - CrashCapture.install(this) } /** Process-wide Room-backed store. One instance shared by the UI and the background service. */ @@ -58,20 +61,74 @@ class NoopApplication : Application() { val deviceRegistry: DeviceRegistry by lazy { DeviceRegistry(WhoopDatabase.get(this)) } /** - * Active device id resolved once at startup from the registry, falling back to the legacy - * "my-whoop" if the registry has none yet (so behaviour is unchanged today). Read with a guarded - * blocking call — a one-off indexed `LIMIT 1` query at composition time. Any failure (e.g. an early - * read before migration) is swallowed and falls back, so startup can never be broken by this. + * Active device id, resolved once at startup from the registry and falling back to the legacy + * "my-whoop" if the registry has none yet. Read with a guarded blocking call — a one-off indexed + * `LIMIT 1` query at composition time. Any failure (e.g. an early read before migration) is swallowed + * and falls back, so startup can never be broken by this. + * + * #1303: NOT a `by lazy`. Serial adoption re-points the ACTIVE device mid-process, and a lazy is + * frozen for the life of the process — so every consumer below kept the pre-adoption id until the next + * cold start, and the engine went on deriving days under it. Field-confirmed on a 5/MG: the registry + * read `whoop-` while the diagnostics export, and the scoring pass, still used the old + * address-based id, splitting the computed history across both until the phone was restarted. Adoption + * now calls [onActiveDeviceAdopted] and the handle follows within the process. + */ + @Volatile + var activeDeviceId: String = "" + get() { + if (field.isEmpty()) { + field = runCatching { runBlocking { deviceRegistry.activeDeviceId() } } + .onFailure { Log.w("NoopApplication", "activeDeviceId resolve failed; using fallback", it) } + .getOrNull() ?: WhoopBleClient.DEFAULT_DEVICE_ID + } + return field + } + private set + + /** + * Point this process at the id a strap just adopted (#1303). + * + * Only the handle moves: the registry write and the row migration have already happened inside + * `adoptSerialIdentity`, and the BLE client is re-pointed by its own caller. Kept narrow and + * idempotent so a reconnect that re-adopts the same id costs nothing. */ - val activeDeviceId: String by lazy { - runCatching { runBlocking { deviceRegistry.activeDeviceId() } } - .onFailure { Log.w("NoopApplication", "activeDeviceId resolve failed; using fallback", it) } - .getOrNull() ?: WhoopBleClient.DEFAULT_DEVICE_ID + fun onActiveDeviceAdopted(newId: String) { + if (newId.isNotEmpty() && newId != activeDeviceId) activeDeviceId = newId + } + + /** + * The id the BLE client should stamp WHOOP samples with at startup (#1881). + * + * [activeDeviceId] answers "which device did the user select", which is NOT the same question once a + * non-WHOOP device can be active: handing it to the client made every WHOOP live sample and historical + * chunk persist under, say, an Oura ring. `adoptSourceIdentity` corrects the id when a strap actually + * connects, but not for the window between construction and that connect, so the wrong value must not + * be adopted in the first place. + * + * Fail-open: an unreadable registry or an unclassifiable row keeps today's behaviour. Only a + * POSITIVELY non-WHOOP active device falls back to the legacy id. Swift twin: `BLEManager.bootstrapStore`. + */ + private fun whoopStartupDeviceId(): String { + val id = activeDeviceId + val rows = runCatching { runBlocking { deviceRegistry.all() } }.getOrNull() ?: return id + val row = rows.firstOrNull { it.id == id } ?: return id + return if (SourceIdentity.isWhoop(row)) id else WhoopBleClient.DEFAULT_DEVICE_ID } /** Process-wide BLE client. Owns the GATT connection and outlives any single Activity/ViewModel. */ val ble: WhoopBleClient by lazy { - WhoopBleClient(applicationContext, repository = repository, deviceId = activeDeviceId).apply { + val startupId = whoopStartupDeviceId() + WhoopBleClient( + applicationContext, + repository = repository, + deviceId = startupId, + successfulOffloadSink = { + SelfHostedPushScheduler.enqueueAfterSuccessfulOffload(applicationContext) + }, + ).apply { + // #1881: the same fact seeds the connect gate, closing the launch race where the radio can + // reach the WHOOP flow before SourceCoordinator has wired up and asserted it. + if (startupId != activeDeviceId) setWhoopIsActiveDevice(false) // Apply the persisted "Debug logging" preference at the composition root so the low-level // client never has to read the UI/prefs layer. Default OFF — see WhoopBleClient.debugLogcat. debugLogcat = NoopPrefs.debugLogging(applicationContext) @@ -106,8 +163,12 @@ class NoopApplication : Application() { // path (status=133 on an OS-bonded strap). Mirrors macOS AppModel.scan() reading the persisted // "selectedWhoopModel". Same-strap switches now adopt in place (no reconnect) via the // coordinator, so this only fires for a genuinely different WHOOP. - startWhoop = { ble.connect(persistedWhoopModel()) }, - stopWhoop = { ble.disconnect() }, + // #1881: the flag rides the SAME two closures, so it inherits the coordinator's semantics + // exactly. `stopWhoop` alone was edge-triggered: it dropped the link once and nothing stopped + // `onBluetoothRadioOn` bringing it straight back — every Bluetooth toggle reached it, and it + // clears `intentionalDisconnect` before reconnecting. Swift twin: AppModel.wireSourceCoordinator. + startWhoop = { ble.setWhoopIsActiveDevice(true); ble.connect(persistedWhoopModel()) }, + stopWhoop = { ble.setWhoopIsActiveDevice(false); ble.disconnect() }, // Multi-WHOOP (MW-2/MW-3): pin the connection to the active WHOOP's persisted address and // re-attribute live samples to it on a WHOOP→WHOOP switch. Both inert on the single-WHOOP // path — the coordinator only invokes them for a non-legacy WHOOP / a non-null peripheralId. diff --git a/android/app/src/main/java/com/noop/ai/AiCoach.kt b/android/app/src/main/java/com/noop/ai/AiCoach.kt index 68c1a4d5c5..beb19e1bfd 100644 --- a/android/app/src/main/java/com/noop/ai/AiCoach.kt +++ b/android/app/src/main/java/com/noop/ai/AiCoach.kt @@ -1,9 +1,12 @@ package com.noop.ai import android.content.Context +import com.noop.analytics.CoachSuggestions import com.noop.analytics.EffectRanker import com.noop.analytics.LabMarkerCategory import com.noop.analytics.MarkerCatalog +import com.noop.analytics.SseDeltas +import com.noop.analytics.SseProvider import com.noop.analytics.StressIndex import com.noop.data.DailyMetric import com.noop.data.JournalEntry @@ -24,10 +27,10 @@ import kotlin.math.roundToInt /** * The AI Coach. * - * Privacy posture: this is the ONE networked feature in the app. Nothing leaves the device - * until the user has saved their own API key (see [AiKeyStore]) and asked a question. Only a - * compact plain-text summary of their metrics plus their question is sent to the provider the - * user picked. No raw samples, no identifiers. + * Privacy posture: this is an opt-in networked feature, independent of the default-off Experimental + * self-hosted push. Nothing leaves through the Coach until the user has saved their own API key (see + * [AiKeyStore]) and asked a question. Only a compact plain-text summary of their metrics plus their + * question is sent to the provider the user picked. No raw samples, no identifiers. * * Anonymous: the only branding is the provider name the user selected. The system prompt does * not name any app author or model vendor. @@ -50,6 +53,10 @@ class AiCoach( * [activeStrapId]. */ private val deviceId = "my-whoop" + // K13: cached summary of the dropped middle turns, regenerated when the dropped set changes. + @Volatile private var droppedSummary: String? = null + @Volatile private var droppedSummaryKey: List = emptyList() + /** The source id native (in-app) journal answers are stored under (matches the UI's * JOURNAL_DEVICE_ID); used for the opt-in on-device-signals context only. */ private val journalDeviceId = "noop-journal" @@ -127,39 +134,106 @@ class AiCoach( // Slide a window over a long conversation so the history can't crowd out the reply on a // small local context window (e.g. Ollama's 2048-token default). The first user turn carries // the data context, so it is always kept; the middle is dropped, the recent tail retained. + // K13: summarize the dropped middle so the model retains context continuity (best-effort). val grounded = trimmedHistory(groundedFull, MAX_HISTORY_TURNS) + val groundedWithSummary = injectDroppedSummary(grounded, groundedFull) when (provider) { AiProvider.OPENAI -> - callOpenAiCompatible(provider, provider.endpoint, model, key, grounded, systemPrompt) + callOpenAiCompatible(provider, provider.endpoint, model, key, groundedWithSummary, systemPrompt) AiProvider.ANTHROPIC -> - callAnthropic(provider, model, key!!, grounded, systemPrompt) + callAnthropic(provider, model, key!!, groundedWithSummary, systemPrompt) AiProvider.GEMINI -> - callGemini(provider, model, key!!, grounded, systemPrompt) + callGemini(provider, model, key!!, groundedWithSummary, systemPrompt) AiProvider.CUSTOM -> callOpenAiCompatible( provider, customChatUrl(customBaseUrl), model, key, - grounded, + groundedWithSummary, systemPrompt, customAuthHeader, ) } } + /** + * K1: Stream the conversation to [provider] using [model], calling [onDelta] for each text + * chunk as it arrives. Same context injection, consent gating, and error mapping as [chat]; + * streaming changes transport, not payload. The concatenated deltas are byte-identical to + * what [chat] would return (parity pin in `SseDeltasTest`). Runs on [Dispatchers.IO]. + * + * On error mid-stream, throws — the caller keeps the partial text and appends an interrupted + * marker. Never crashes; the ViewModel maps exceptions to a visible error. + */ + suspend fun chatStream( + ctx: Context, + history: List, + provider: AiProvider, + model: String, + consent: Boolean = false, + customBaseUrl: String = "", + customAuthHeader: CustomAiAuthHeader = CustomAiAuthHeader.BEARER, + includeSignals: Boolean = false, + onDelta: (String) -> Unit, + ): Unit = withContext(Dispatchers.IO) { + val key = AiKeyStore.read(ctx, provider) + if (key == null && provider != AiProvider.CUSTOM) { + throw Exception("No API key set. Add your ${provider.displayName} key to use the coach.") + } + if (provider == AiProvider.CUSTOM) { + require(customBaseUrl.isNotBlank()) { "Set your server URL first." } + require(model.isNotBlank()) { "Pick a model your server serves." } + } + + require(history.isNotEmpty()) { "Ask a question first." } + require(history.last().role == "user") { "The last message must be your question." } + + val groundedFull = if (consent) { + val days = runCatching { repo.daysMerged(activeStrapId()) }.getOrDefault(emptyList()) + val stress = runCatching { stressLineToday() }.getOrNull() + val signals = if (includeSignals) runCatching { buildSignalsContext() }.getOrNull() else null + val full = buildString { + append(buildContext(days)) + if (!stress.isNullOrBlank()) append("\n\n").append(stress) + if (!signals.isNullOrBlank()) append("\n\n").append(signals) + } + injectContext(history, full) + } else { + injectContext(history, NO_CONSENT_NOTE) + } + + val systemPrompt = resolveSystemPrompt(ctx) + val grounded = trimmedHistory(groundedFull, MAX_HISTORY_TURNS) + val groundedWithSummary = injectDroppedSummary(grounded, groundedFull) + + when (provider) { + AiProvider.OPENAI -> + callOpenAiCompatibleStream(provider, provider.endpoint, model, key, groundedWithSummary, systemPrompt, onDelta) + AiProvider.ANTHROPIC -> + callAnthropicStream(provider, model, key!!, groundedWithSummary, systemPrompt, onDelta) + AiProvider.GEMINI -> + callGeminiStream(provider, model, key!!, groundedWithSummary, systemPrompt, onDelta) + AiProvider.CUSTOM -> + callOpenAiCompatibleStream( + provider, customChatUrl(customBaseUrl), model, key, groundedWithSummary, systemPrompt, onDelta, customAuthHeader, + ) + } + } + /** * Today's derived stress line for the consent-gated coach context. Reads R-R for the local day - * via [WhoopRepository.rrIntervals] (the SAME path StressScreen uses) and summarises it with the - * pure [stressIndexLine]. Returns null when there aren't enough clean beats. Summary number only, * the raw R-R never leaves the device. + * via [WhoopRepository.rrIntervalsUnion] (the SAME path StressScreen uses) and summarises it with the + * pure [stressIndexLine]. Returns null when there aren't enough clean beats. Summary number only; + * the raw R-R never leaves the device. */ private suspend fun stressLineToday(): String? { val nowSeconds = System.currentTimeMillis() / 1000L val tzOffset = java.util.TimeZone.getDefault().getOffset(nowSeconds * 1_000L) / 1_000L val localNow = nowSeconds + tzOffset val from = (localNow - Math.floorMod(localNow, 86_400L)) - tzOffset - val rr = repo.rrIntervals(activeStrapId(), from, nowSeconds, limit = 200_000) + val rr = repo.rrIntervalsUnion(activeStrapId(), from, nowSeconds, limit = 200_000) return stressIndexLine(rr) } @@ -220,6 +294,85 @@ class AiCoach( }.getOrDefault(emptyList()) } + // --------------------------------------------------------------------------------------- + // K5: scheduled morning-brief generation (headless, no chat UI involved) + // --------------------------------------------------------------------------------------- + + /** + * K5: generate today's coaching brief with NO chat transcript involved — used by the scheduled + * morning-brief notification ([com.noop.notif.CoachBriefWorker]), which runs headless (no + * Activity/ViewModel) and must not touch [CoachViewModel]'s in-memory messages. Non-streaming (a + * WorkManager worker has no UI to stream into). Triple-gated: a saved key (or a committed Custom + * server), [consent], and the brief instruction shared with the Swift twin. Returns null on any + * failure (no key, no consent, network, rate limit, empty reply) — the caller treats null as + * "brief unavailable"; never throws. + */ + suspend fun generateBrief( + ctx: Context, + provider: AiProvider, + model: String, + consent: Boolean, + customBaseUrl: String = "", + customAuthHeader: CustomAiAuthHeader = CustomAiAuthHeader.BEARER, + includeSignals: Boolean = false, + ): String? { + if (!consent) return null + val key = AiKeyStore.read(ctx, provider) + if (key == null && provider != AiProvider.CUSTOM) return null + if (provider == AiProvider.CUSTOM && (customBaseUrl.isBlank() || model.isBlank())) return null + return runCatching { + val history = listOf(ChatMsg(role = "user", text = BRIEF_INSTRUCTION)) + chat(ctx, history, provider, model, consent, customBaseUrl, customAuthHeader, includeSignals) + .trim() + .ifBlank { null } + }.getOrNull() + } + + // --------------------------------------------------------------------------------------- + // Contextual suggestion chips + // --------------------------------------------------------------------------------------- + + /** + * Contextual suggestion chips for the composer, derived from today's bands via + * [CoachSuggestions]. Reads only on-device `daysMerged` (the same merged read the context + * builder uses, so a live strap's scores under "my-whoop-noop" are not missed); pure, + * byte-identical to the Swift twin `AICoachEngine.suggestions`. Returns the stable generic + * fallback when there is no usable data. Best-effort: a repo failure yields the fallback, + * never throws. + */ + suspend fun suggestions(): List = withContext(Dispatchers.IO) { + val days = runCatching { repo.daysMerged(activeStrapId()) }.getOrDefault(emptyList()) + CoachSuggestions.suggestions(days.lastOrNull(), days) + } + + // K7: Follow-up suggestion chips shown after each assistant reply. These are generic + // conversational follow-ups (not data-derived) so the user can dig deeper without typing. + // Byte-identical to the Swift twin's `AICoachEngine.followUpSuggestions`. + val followUpSuggestions: List = listOf( + "Tell me more about that", + "What should I do next?", + "How does today compare to this week?", + "Give me a specific action plan", + ) + + // K13: Inject the cached summary of the dropped middle into the first user turn of the + // windowed history, so the model sees continuity instead of a gap. Best-effort: when no + // summary is cached, returns the windowed list unchanged (the old gap behaviour). + private fun injectDroppedSummary( + windowed: List, + full: List, + ): List { + val summary = droppedSummary ?: return windowed + if (windowed.isEmpty()) return windowed + // Only inject when the window actually dropped messages (the full list is longer). + if (full.size <= MAX_HISTORY_TURNS + 1) return windowed + val firstUserIdx = windowed.indexOfFirst { it.role == "user" } + if (firstUserIdx < 0) return windowed + val first = windowed[firstUserIdx] + val annotated = first.copy(text = "$summary\n\n---\n\n${first.text}") + return windowed.toMutableList().also { it[firstUserIdx] = annotated } + } + // --------------------------------------------------------------------------------------- // Context builder // --------------------------------------------------------------------------------------- @@ -254,8 +407,19 @@ class AiCoach( val sleepH = d.totalSleepMin?.let { fmt1(it / 60.0) + "h" } ?: "-" val hrv = d.avgHrv?.let { "${it.roundToInt()}ms" } ?: "-" val rhr = d.restingHr?.let { "${it}bpm" } ?: "-" + // The stage breakdown and efficiency, which the coach could not see at all: a user asked why + // it said it had no access to sleep stages, and it was answering honestly — `rest 7.8h` was + // every word it got about a night. These sit on the SAME DailyMetric this line already reads. + // Always emitted, "-" when absent, like every other field: a night with no staging then says + // so, rather than the schema changing shape between days and inviting the model to read a + // missing field as a zero. Twin of the Swift `AICoach.dayLine`. + val deep = d.deepMin?.let { fmt1(it / 60.0) + "h" } ?: "-" + val rem = d.remMin?.let { fmt1(it / 60.0) + "h" } ?: "-" + val light = d.lightMin?.let { fmt1(it / 60.0) + "h" } ?: "-" + val eff = effPctOrDash(d.efficiency) sb.append( - " ${d.day}: charge $recovery, effort $strain, rest $sleepH, HRV $hrv, RHR $rhr\n" + " ${d.day}: charge $recovery, effort $strain, rest $sleepH, " + + "deep $deep, REM $rem, light $light, eff $eff, HRV $hrv, RHR $rhr\n" ) } @@ -307,11 +471,12 @@ class AiCoach( val sb = StringBuilder() // --- Strongest associations on the user's own logged days (recovery as the outcome) --- - val behaviours = runCatching { journalBehaviours() }.getOrDefault(emptyMap()) + val (behaviours, controls) = runCatching { journalBehaviours() } + .getOrDefault(emptyMap>() to emptyMap()) val days = runCatching { repo.daysMerged(activeStrapId()) }.getOrDefault(emptyList()) if (behaviours.isNotEmpty() && days.isNotEmpty()) { val recoveryByDay = days.mapNotNull { d -> d.recovery?.let { d.day to it } }.toMap() - val ranked = runCatching { EffectRanker.rank(behaviours, recoveryByDay, "Charge") } + val ranked = runCatching { EffectRanker.rank(behaviours, controls, recoveryByDay, "Charge") } .getOrDefault(emptyList()) .take(3) if (ranked.isNotEmpty()) { @@ -340,16 +505,22 @@ class AiCoach( return sb.toString().trim().takeIf { it.isNotEmpty() } } - /** Behaviour → set of "yyyy-MM-dd" days it was logged "yes" (imported ∪ native), for EffectRanker. */ - private suspend fun journalBehaviours(): Map> { + /** Behaviour → the days it was logged YES and the days it was logged NO (imported ∪ native), for + * EffectRanker. Kept apart: a day with no journal row for a question belongs to neither, so an + * unanswered day is never counted as a No — see [EffectRanker.effect]. */ + private suspend fun journalBehaviours(): Pair>, Map>> { val imported = repo.journal(deviceId, "0000-01-01", "9999-12-31") val native = repo.journal(journalDeviceId, "0000-01-01", "9999-12-31") val byKey = LinkedHashMap, JournalEntry>() for (e in imported) byKey[e.day to e.question] = e for (e in native) byKey[e.day to e.question] = e // native wins on a collision - val out = HashMap>() - for (e in byKey.values) if (e.answeredYes) out.getOrPut(e.question) { mutableSetOf() }.add(e.day) - return out.mapValues { it.value.toSet() } + val yes = HashMap>() + val no = HashMap>() + for (e in byKey.values) { + val bucket = if (e.answeredYes) yes else no + bucket.getOrPut(e.question) { mutableSetOf() }.add(e.day) + } + return yes.mapValues { it.value.toSet() } to no.mapValues { it.value.toSet() } } /** The latest reading per Lab Book marker key (stored under the ACTIVE strap deviceId). #1304/#512: @@ -646,6 +817,151 @@ class AiCoach( return reply } + // --------------------------------------------------------------------------------------- + // K1: Streaming provider calls (SSE via OkHttp BufferedSource) + // --------------------------------------------------------------------------------------- + + /** Stream an OpenAI-compatible chat (OpenAI + Custom). Same body as [callOpenAiCompatible]'s + * standard-params path, with `stream: true`. SSE parsed via [SseDeltas.openAiDelta]. */ + private fun callOpenAiCompatibleStream( + provider: AiProvider, + url: String, + model: String, + key: String?, + history: List, + systemPrompt: String, + onDelta: (String) -> Unit, + customAuthHeader: CustomAiAuthHeader = CustomAiAuthHeader.BEARER, + ) { + val messages = JSONArray() + messages.put(JSONObject().put("role", "system").put("content", systemPrompt)) + for (m in history) messages.put(JSONObject().put("role", m.role).put("content", m.text)) + + val body = JSONObject() + .put("model", model) + .put("messages", messages) + .put("temperature", 0.6) + .put("max_tokens", 4096) + .put("stream", true) + .toString() + + val builder = Request.Builder().url(url).addHeader("Content-Type", "application/json") + .post(body.toRequestBody(JSON)) + when (provider) { + AiProvider.CUSTOM -> applyCustomAuthHeader(builder, key, customAuthHeader) + else -> if (!key.isNullOrBlank()) builder.addHeader("Authorization", "Bearer $key") + } + executeStreaming(builder.build(), provider, onDelta) { payload -> SseDeltas.openAiDelta(payload) } + } + + /** Stream an Anthropic chat. Same body as [callAnthropic], with `stream: true`. SSE parsed + * via [SseDeltas.anthropicDelta]. */ + private fun callAnthropicStream( + provider: AiProvider, + model: String, + key: String, + history: List, + systemPrompt: String, + onDelta: (String) -> Unit, + ) { + val messages = JSONArray() + for (m in history) messages.put(JSONObject().put("role", m.role).put("content", m.text)) + + val body = JSONObject() + .put("model", model) + .put("max_tokens", 4096) + .put("system", systemPrompt) + .put("messages", messages) + .put("stream", true) + .toString() + + val request = Request.Builder().url(provider.endpoint) + .addHeader("x-api-key", key) + .addHeader("anthropic-version", "2023-06-01") + .addHeader("content-type", "application/json") + .post(body.toRequestBody(JSON)) + .build() + + executeStreaming(request, provider, onDelta) { payload -> SseDeltas.anthropicDelta(payload) } + } + + /** Stream a Gemini chat via `:streamGenerateContent?alt=sse`. Same body as [callGemini]. + * SSE parsed via [SseDeltas.geminiDelta]. */ + private fun callGeminiStream( + provider: AiProvider, + model: String, + key: String, + history: List, + systemPrompt: String, + onDelta: (String) -> Unit, + ) { + val contents = JSONArray() + for (m in history) { + contents.put( + JSONObject() + .put("role", if (m.role == "assistant") "model" else "user") + .put("parts", JSONArray().put(JSONObject().put("text", m.text))), + ) + } + + val body = JSONObject() + .put("system_instruction", JSONObject().put("parts", JSONArray().put(JSONObject().put("text", systemPrompt)))) + .put("contents", contents) + .put("generationConfig", JSONObject().put("temperature", 0.6).put("maxOutputTokens", 4096)) + .toString() + + val request = Request.Builder() + .url("${provider.endpoint}/$model:streamGenerateContent?alt=sse") + .addHeader("x-goog-api-key", key) + .addHeader("content-type", "application/json") + .post(body.toRequestBody(JSON)) + .build() + + executeStreaming(request, provider, onDelta) { payload -> SseDeltas.geminiDelta(payload) } + } + + /** Execute a streaming SSE request, reading the body line-by-line. For each `data:` payload + * line, calls [extractDelta] to get the text chunk and passes it to [onDelta]. Same HTTP + * error mapping as [execute]. K1. */ + private fun executeStreaming( + request: Request, + provider: AiProvider, + onDelta: (String) -> Unit, + extractDelta: (String) -> String?, + ) { + try { + http.newCall(request).execute().use { resp -> + if (resp.code !in 200..299) { + val body = resp.body?.string().orEmpty() + throw httpError(provider, resp.code, body) + } + val source = resp.body?.source() + ?: throw Exception("The provider returned an empty streaming response.") + while (!source.exhausted()) { + val line = source.readUtf8Line() ?: break + val payload = SseDeltas.dataPayload(fromLine = line) ?: continue + val delta = extractDelta(payload) ?: continue + onDelta(delta) + } + } + } catch (e: java.net.UnknownHostException) { + throw Exception("No internet connection. The coach needs a connection to reach the provider.") + } catch (e: java.net.SocketTimeoutException) { + throw Exception("The request timed out. Please check your connection and try again.") + } catch (e: javax.net.ssl.SSLException) { + throw Exception("A secure connection to the provider could not be established.") + } catch (e: java.io.IOException) { + val msg = e.message.orEmpty() + if (msg.contains("Cleartext", ignoreCase = true) && msg.contains("not permitted", ignoreCase = true)) { + throw Exception( + "Plain http:// is blocked for that host. Use localhost, 127.0.0.1, 10.0.2.2, " + + "a .local hostname, or switch the server to https://." + ) + } + throw Exception(msg.ifBlank { "A network error occurred while streaming the reply." }) + } + } + // --------------------------------------------------------------------------------------- // HTTP / error plumbing // --------------------------------------------------------------------------------------- @@ -712,6 +1028,27 @@ class AiCoach( // Small numeric formatting helpers // --------------------------------------------------------------------------------------- + /** + * Efficiency as a percentage, NORMALISING the stored value first. + * + * `DailyMetric.efficiency` is not reliably a 0-1 fraction - it arrives as a percentage on some + * import paths, which SleepMetricDetailLogic and SleepModelLogic each guard against inline. A bare + * `* 100` would hand the coach "eff 9400%" for an imported night, and a model given a nonsense + * number reasons about it confidently rather than ignoring it. + * + * 1.5 rather than 1.0 because a genuine fraction can exceed 1.0 only by floating-point noise, while + * a genuine percentage is 30-100. The two existing Kotlin copies split at 1.0; matching the Swift + * twin here keeps the COACH line consistent across platforms, and the wider divergence between + * those thresholds is pre-existing and not this change's to settle. + */ + private fun effPctOrDash(raw: Double?): String { + var e = raw ?: return "-" + if (e <= 0.0) return "-" + if (e > 1.5) e /= 100.0 + if (e <= 0.0 || e > 1.0) return "-" + return "${(e * 100).roundToInt()}%" + } + private fun fmt1(v: Double): String = if (v == v.roundToInt().toDouble()) v.roundToInt().toString() else String.format("%.1f", v) @@ -897,7 +1234,8 @@ class AiCoach( const val DEFAULT_SYSTEM_PROMPT = "You are an elite, supportive recovery and performance coach with a real training " + "methodology. You may be given a summary of the user's own wearable data (charge " + - "0-100, effort 0-100, rest/sleep, HRV, resting heart rate) and recent workouts. " + + "0-100, effort 0-100, rest/sleep and its deep/REM/light breakdown, sleep " + + "efficiency, HRV, resting heart rate) and recent workouts. " + "Charge is the daily recovery/readiness score; effort is the day's cardiovascular " + "load. Coach using autoregulation: charge 67-100 = green light to build/push, " + "higher effort is fine; 34-66 = maintain, quality over volume, keep it controlled; " + @@ -917,6 +1255,16 @@ class AiCoach( "NOTE: The user has not granted access to their biometric data. Coach generally and " + "encourage them to enable \"Let the coach use my data\" for tailored guidance." + /** + * K5: the brief instruction shared by [generateBrief] (headless, scheduled) — byte-identical to + * the Swift twin's `AICoachEngine.briefInstruction` so a brief reads the same on both platforms. + */ + const val BRIEF_INSTRUCTION = + "Based on the data above, give me TODAY'S coaching brief in three short parts: " + + "(1) my readiness in one line, citing charge, HRV and rest; " + + "(2) exactly what training to do today and what to avoid; " + + "(3) one specific thing to improve my charge. Be punchy and motivating." + /** * The system prompt actually sent: the user's edited override from [NoopPrefs] when it is * non-blank, otherwise [DEFAULT_SYSTEM_PROMPT]. Read fresh per request so an edit in the Coach diff --git a/android/app/src/main/java/com/noop/ai/ChatMsg.kt b/android/app/src/main/java/com/noop/ai/ChatMsg.kt index a7fd698aeb..2e976eb623 100644 --- a/android/app/src/main/java/com/noop/ai/ChatMsg.kt +++ b/android/app/src/main/java/com/noop/ai/ChatMsg.kt @@ -3,11 +3,15 @@ package com.noop.ai /** * One turn in the coach conversation. * + * @param id stable identifier (UUID string). Used by K1 streaming to find and replace the + * placeholder assistant message as chunks arrive. Defaults to a new UUID so existing callers + * are unaffected. * @param role "user" or "assistant" — the only two roles the UI history carries. The * system prompt is supplied separately by [AiCoach] and is never stored here. * @param text plain-text message body. */ data class ChatMsg( + val id: String = java.util.UUID.randomUUID().toString(), val role: String, // "user" | "assistant" val text: String, ) diff --git a/android/app/src/main/java/com/noop/alarm/SleepWindowWatcher.kt b/android/app/src/main/java/com/noop/alarm/SleepWindowWatcher.kt index 7ebd04ccf8..1529e11873 100644 --- a/android/app/src/main/java/com/noop/alarm/SleepWindowWatcher.kt +++ b/android/app/src/main/java/com/noop/alarm/SleepWindowWatcher.kt @@ -30,6 +30,23 @@ class SleepWindowWatcher( /** Set once we've advanced the alarm so we don't keep re-advancing every sample. */ private var fired: Boolean = false + /** + * The trough established so far, or null while no reading has qualified as a candidate. READ-ONLY + * diagnostics (#1858) — the detector's own decisions do not consult it. + * + * Null is the interesting value: it means no reading has ever been BELOW [troughCeilingBpm], which + * is what happens when the user is awake for the whole window, and it is the state [shouldWake] + * treats as "no reference yet" and refuses to fire on. + */ + val trough: Int? get() = if (troughBpm == Int.MAX_VALUE) null else troughBpm + + /** Readings folded in since [reset]. READ-ONLY diagnostics: distinguishes "no live HR" from + * "HR arrived but the heuristic never fired". */ + val samples: Int get() = sampleCount + + /** Whether the alarm has already been advanced this window. READ-ONLY diagnostics. */ + val hasFired: Boolean get() = fired + /** Reset for a fresh night (called when the watcher (re)enters a window). */ fun reset() { troughBpm = Int.MAX_VALUE diff --git a/android/app/src/main/java/com/noop/alarm/SmartAlarmBootReceiver.kt b/android/app/src/main/java/com/noop/alarm/SmartAlarmBootReceiver.kt index 01ce1fa9e4..08066c0c01 100644 --- a/android/app/src/main/java/com/noop/alarm/SmartAlarmBootReceiver.kt +++ b/android/app/src/main/java/com/noop/alarm/SmartAlarmBootReceiver.kt @@ -27,8 +27,12 @@ class SmartAlarmBootReceiver : BroadcastReceiver() { runCatching { val wind = WindDownStore.from(context) if (wind.enabled) { - val wake = SmartAlarmStore.from(context).targetMinutes - WindDownScheduler.schedule(context, wind, wake) + val alarm = SmartAlarmStore.from(context) + // #1858: the per-day overrides go through here too. This is the ONE call site that + // reschedules without a user action, so dropping them here does not fail loudly — + // it quietly collapses seven weekday nudges back to one daily nudge on the default + // wake time, and stays that way until the user next edits a setting. + WindDownScheduler.schedule(context, wind, alarm.targetMinutes, alarm.targetOverrides) } } } diff --git a/android/app/src/main/java/com/noop/alarm/SmartAlarmScheduler.kt b/android/app/src/main/java/com/noop/alarm/SmartAlarmScheduler.kt index 41f970f2c5..cd1a3926f9 100644 --- a/android/app/src/main/java/com/noop/alarm/SmartAlarmScheduler.kt +++ b/android/app/src/main/java/com/noop/alarm/SmartAlarmScheduler.kt @@ -50,28 +50,29 @@ object SmartAlarmScheduler { fun arm(context: Context, store: SmartAlarmStore, afterFire: Boolean = false): Long? { if (!canScheduleExact(context)) return null - val deadlineMin = (store.targetMinutes + store.windowMinutes) - // The window's hard edge can roll past midnight (e.g. 23:50 + 30). Compute the next wall-clock - // occurrence of that absolute minute-of-day, then derive the window-start from it so the two - // edges stay on the same night even across the midnight boundary. + // #1858: the deadline minute is per-day now, so the DAY is chosen first and its own target read + // from it. The window start is still derived from the deadline, so the two edges stay on the same + // night even when the window opens the previous evening. val weekdays = store.weekdays - val deadline = nextOccurrence(deadlineMin % SmartAlarmStore.MINUTES_PER_DAY, weekdays) - // Re-arm after a fire (audit): when the smart alarm fires EARLY on a light-sleep phase (e.g. 06:35 - // for a 07:00 deadline), the deadline's next occurrence is still TODAY 07:00 — so a plain re-arm - // scheduled a second guaranteed wake the SAME morning, waking the user again. We just woke them - // today, so the next wake must be tomorrow: push a same-day deadline forward one day. - if (afterFire) { - val now = Calendar.getInstance() - val sameDay = deadline.get(Calendar.YEAR) == now.get(Calendar.YEAR) && - deadline.get(Calendar.DAY_OF_YEAR) == now.get(Calendar.DAY_OF_YEAR) - if (sameDay) { - deadline.add(Calendar.DAY_OF_YEAR, 1) - // …and that push can land on a day the alarm is switched off, so re-apply the weekday - // filter. Without this, re-arming after a Saturday fire would schedule Sunday even with - // Sunday deselected — the one path that reaches a disabled day, because it moves the - // deadline AFTER nextOccurrence already filtered it. - advanceToEnabledDay(deadline, weekdays) - } + val deadline = nextDeadline( + now = Calendar.getInstance(), + weekdays = weekdays, + windowMinutes = store.windowMinutes, + afterFire = afterFire, + ) { store.targetFor(it) } ?: run { + // No day is reachable, so there IS no next wake — clear the persisted edges rather than + // leaving the previous ones behind. The watcher reads exactly those two fields to decide it is + // inside a wake window, so a stale pair is a phantom window: it would keep feeding HR to the + // detector and could advance an alarm that no longer exists. + // + // Deliberately NOT the same as the `canScheduleExact` bail above. There the intent still + // stands and the OS is only refusing right now, so the stored edges must survive for + // `rearmPersisted` to retry. Here there is nothing to retry. + // + // `SmartAlarmStore.weekdays` filters to 1..7, so this is unreachable through the store today; + // the guard is for the caller that forgets, on a path where the failure is silent. + cancel(context, store) + return null } val windowStartMs = deadline.timeInMillis - store.windowMinutes.toLong() * 60_000L @@ -155,35 +156,80 @@ object SmartAlarmScheduler { ) } - /** The next wall-clock occurrence (today or tomorrow) of an absolute minute-of-day. */ - private fun nextOccurrence(minuteOfDay: Int, weekdays: Set = emptySet()): Calendar = - Calendar.getInstance().apply { - set(Calendar.HOUR_OF_DAY, minuteOfDay / 60) - set(Calendar.MINUTE, minuteOfDay % 60) - set(Calendar.SECOND, 0) - set(Calendar.MILLISECOND, 0) - if (timeInMillis <= System.currentTimeMillis()) add(Calendar.DAY_OF_YEAR, 1) - advanceToEnabledDay(this, weekdays) - } + /** + * The EARLIEST-wake minute of the next scheduled window (#1858) — what the guarantee card names. + * + * The card promises a specific time ("a backup alarm is set for 04:45"), so once wake times vary by + * day it has to show the next one rather than the default; on a moved day the default is simply the + * wrong number. DERIVED from [nextDeadline] rather than recomputed, so the card and the alarm cannot + * disagree — two independent time computations drifting apart is the failure this change already had + * to fix in two other places. + * + * Falls back to [defaultTarget] when no day is reachable, so the card degrades to the previous + * behaviour rather than blanking. Pure but for the clock, so it is testable without Compose. + */ + internal fun nextWindowStartMinutes( + now: Calendar, + weekdays: Set, + windowMinutes: Int, + defaultTarget: Int, + targetForDay: (Int) -> Int, + ): Int { + val deadline = nextDeadline(now, weekdays, windowMinutes, afterFire = false, targetForDay) + ?: return defaultTarget + val deadlineMin = deadline.get(Calendar.HOUR_OF_DAY) * 60 + deadline.get(Calendar.MINUTE) + return (deadlineMin - windowMinutes + SmartAlarmStore.MINUTES_PER_DAY) % + SmartAlarmStore.MINUTES_PER_DAY + } /** - * Roll `cal` forward until it lands on a day the alarm is allowed to fire on. + * The next hard-deadline instant, honouring PER-DAY wake times (#1858). + * + * Replaces the old "find the next occurrence of one minute-of-day, then roll to an enabled day": + * with per-day targets the deadline minute is no longer a single number, so the day must be chosen + * FIRST and its own target read from it. + * + * Walks candidate days forward from today. For each day the alarm is allowed to fire on, the deadline + * is that day's own `target + window`, and the first such instant strictly in the future wins. The + * weekday tested is therefore still the day the DEADLINE lands on — the morning you are actually + * woken — which is what [SmartAlarmStore.weekdays] has always meant and what a person means by + * "Monday at 04:45". * - * EMPTY [weekdays] means every day, so this is a no-op — that is the pre-weekday behaviour and the - * default for every existing install, which is why the whole feature can ship without a migration. + * `target + window` can roll past midnight (23:50 + 30). Taking it modulo the day places the deadline + * on the enabled day itself and lets the WINDOW START fall on the previous evening, which is the same + * relationship the single-time version produced. * - * The 7-iteration bound is a guard, not an expectation: [SmartAlarmStore.weekdays] already filters - * to 1..7, so a non-empty set always contains a reachable day and the loop exits in at most six - * steps. If a future caller ever passes an unreachable set, this returns the unshifted day rather - * than spinning — a wrong-day alarm is recoverable, a hung scheduler on the safety-critical path is - * not. Kept `internal` so the day-selection can be unit-tested without an AlarmManager. + * [afterFire] skips today outright. When the smart logic fires early — 04:35 for an 04:45 deadline — + * today's deadline is still ahead, and re-arming onto it would wake the user a second time the same + * morning. Skipping the day is a cleaner statement of that than adjusting a date afterwards, and it + * cannot land on a disabled day because the loop only ever considers enabled ones. + * + * Pure but for the clock passed in, so every one of those cases is unit-testable without an + * AlarmManager. Returns null only if no day is reachable, which [SmartAlarmStore.weekdays]'s 1..7 + * filter already prevents. */ - internal fun advanceToEnabledDay(cal: Calendar, weekdays: Set) { - if (weekdays.isEmpty()) return - var guard = 0 - while (cal.get(Calendar.DAY_OF_WEEK) !in weekdays && guard < 7) { - cal.add(Calendar.DAY_OF_YEAR, 1) - guard++ + internal fun nextDeadline( + now: Calendar, + weekdays: Set, + windowMinutes: Int, + afterFire: Boolean = false, + targetForDay: (Int) -> Int, + ): Calendar? { + for (offset in 0..7) { + if (afterFire && offset == 0) continue + val candidate = (now.clone() as Calendar).apply { + add(Calendar.DAY_OF_YEAR, offset) + } + val dow = candidate.get(Calendar.DAY_OF_WEEK) + if (weekdays.isNotEmpty() && dow !in weekdays) continue + val deadlineMin = + (targetForDay(dow) + windowMinutes) % SmartAlarmStore.MINUTES_PER_DAY + candidate.set(Calendar.HOUR_OF_DAY, deadlineMin / 60) + candidate.set(Calendar.MINUTE, deadlineMin % 60) + candidate.set(Calendar.SECOND, 0) + candidate.set(Calendar.MILLISECOND, 0) + if (candidate.timeInMillis > now.timeInMillis) return candidate } + return null } } diff --git a/android/app/src/main/java/com/noop/alarm/SmartAlarmStore.kt b/android/app/src/main/java/com/noop/alarm/SmartAlarmStore.kt index c340fc47d8..8174a2e1a0 100644 --- a/android/app/src/main/java/com/noop/alarm/SmartAlarmStore.kt +++ b/android/app/src/main/java/com/noop/alarm/SmartAlarmStore.kt @@ -56,6 +56,46 @@ class SmartAlarmStore(private val prefs: SharedPreferences) { .putStringSet(KEY_WEEKDAYS, v.filter { it in 1..7 }.map { it.toString() }.toSet()) .apply() + /** + * PER-DAY wake times (#1858): `Calendar.DAY_OF_WEEK` → earliest-wake minutes, for the days that + * differ from [targetMinutes]. + * + * A sparse OVERRIDE map, not a full schedule: absent days fall back to [targetMinutes], so an install + * that never sets one behaves exactly as before and there is nothing to migrate. Reported as + * "04:45 three days a week and 03:30 on two others" — a shape the single-time model could not express + * at all, so the honest answer to that user was "the app cannot do this", not "you set it up wrong". + * + * Keyed on the day the HARD DEADLINE lands on — the morning you are actually woken — matching + * [weekdays] and what a person means by "Monday at 04:45". Stored as `day:minutes` strings so the + * whole map lives in one SharedPreferences key with no serialiser; entries outside 1..7 or outside a + * valid minute-of-day are dropped on read, so a corrupted value can never schedule a bogus time. + */ + var targetOverrides: Map + get() = prefs.getStringSet(KEY_TARGET_OVERRIDES, emptySet()) + .orEmpty() + .mapNotNull { entry -> + val parts = entry.split(':') + val day = parts.getOrNull(0)?.toIntOrNull() + val minutes = parts.getOrNull(1)?.toIntOrNull() + if (day != null && minutes != null && day in 1..7 && minutes in 0 until MINUTES_PER_DAY) { + day to minutes + } else { + null + } + } + .toMap() + set(v) = prefs.edit() + .putStringSet( + KEY_TARGET_OVERRIDES, + v.filter { (d, m) -> d in 1..7 && m in 0 until MINUTES_PER_DAY } + .map { (d, m) -> "$d:$m" } + .toSet(), + ) + .apply() + + /** The earliest-wake minute for [dayOfWeek] — its override, else the single [targetMinutes]. */ + fun targetFor(dayOfWeek: Int): Int = targetOverrides[dayOfWeek] ?: targetMinutes + /** The wall-clock epoch (ms) of the currently-scheduled HARD deadline, or 0 if none. Persisted so * the boot receiver can re-arm the exact alarm after a restart without recomputing intent. */ var scheduledDeadlineMs: Long @@ -73,6 +113,7 @@ class SmartAlarmStore(private val prefs: SharedPreferences) { private const val KEY_TARGET = "alarm.targetMinutes" private const val KEY_WINDOW = "alarm.windowMinutes" private const val KEY_WEEKDAYS = "alarm.weekdays" + private const val KEY_TARGET_OVERRIDES = "alarm.targetOverrides" private const val KEY_DEADLINE_MS = "alarm.scheduledDeadlineMs" private const val KEY_WINDOW_START_MS = "alarm.scheduledWindowStartMs" diff --git a/android/app/src/main/java/com/noop/alarm/WindDownScheduler.kt b/android/app/src/main/java/com/noop/alarm/WindDownScheduler.kt index e1651d4116..8505367bb7 100644 --- a/android/app/src/main/java/com/noop/alarm/WindDownScheduler.kt +++ b/android/app/src/main/java/com/noop/alarm/WindDownScheduler.kt @@ -35,24 +35,59 @@ object WindDownScheduler { * prior schedule first so a settings change doesn't stack two nudges. No-op'd by the caller when * the nudge is disabled (it calls [cancel] instead). */ - fun schedule(context: Context, store: WindDownStore, wakeMinutes: Int) { + fun schedule( + context: Context, + store: WindDownStore, + wakeMinutes: Int, + perDayWake: Map = emptyMap(), + ) { val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager - val pi = nudgePendingIntent(context) - am.cancel(pi) - val minuteOfDay = store.nudgeMinuteOfDay(wakeMinutes) - val first = nextOccurrence(minuteOfDay) - // Inexact, repeating, NOT wakeup — a wind-down reminder doesn't need to punch through Doze. - am.setInexactRepeating( - AlarmManager.RTC, - first.timeInMillis, - AlarmManager.INTERVAL_DAY, - pi, - ) + // Cancel BOTH shapes first. Switching between them changes how many alarms exist, so cancelling + // only the one about to be re-scheduled would strand the other's — seven weekly nudges left + // running after a switch back to daily, or a stale daily one firing beside the seven. + cancel(context) + + if (perDayWake.isEmpty()) { + val first = nextOccurrence(store.nudgeMinuteOfDay(wakeMinutes)) + // Inexact, repeating, NOT wakeup — a wind-down reminder doesn't need to punch through Doze. + am.setInexactRepeating( + AlarmManager.RTC, + first.timeInMillis, + AlarmManager.INTERVAL_DAY, + nudgePendingIntent(context), + ) + return + } + + // #1858 (Apple parity): with per-day wake times set, fan out to seven weekday-pinned nudges each + // at that day's own time. Mirrors `WindDownNudge`'s #554 fan-out, which does exactly this with + // seven UNCalendarNotificationTriggers — the nudge exists to be an hour before YOUR wake, so on a + // day whose wake moved it has to move with it. + // + // A weekly repeat is a daily interval times seven; each day carries its own request code so the + // seven PendingIntents are distinct rather than overwriting one another. + for (weekday in 1..7) { + val wake = perDayWake[weekday] ?: wakeMinutes + // The nudge is pinned to the day it FIRES on, which is not always the day you wake on: an + // early wake pushes it back over midnight. See [WindDownStore.nudgeDayShift]. + val nudgeWeekday = shiftWeekday(weekday, store.nudgeDayShift(wake)) + val first = nextWeeklyOccurrence(store.nudgeMinuteOfDay(wake), nudgeWeekday) + am.setInexactRepeating( + AlarmManager.RTC, + first.timeInMillis, + AlarmManager.INTERVAL_DAY * 7, + nudgePendingIntent(context, weekday), + ) + } } + /** Cancel every shape the nudge can be scheduled in — the single daily one AND all seven weekday + * pins. Unconditional on purpose: the caller does not always know which shape is live, and a + * cancel that misses one leaves a reminder firing at a time the user has already changed. */ fun cancel(context: Context) { val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager am.cancel(nudgePendingIntent(context)) + for (weekday in 1..7) am.cancel(nudgePendingIntent(context, weekday)) } /** Raise the low-key nudge notification. Called from [WindDownReceiver]. */ @@ -78,10 +113,12 @@ object WindDownScheduler { } } - private fun nudgePendingIntent(context: Context): PendingIntent { + /** [weekday] null = the single daily nudge; 1..7 = that weekday's own pin, on its own request code + * so the seven do not collide with each other or with the daily one. */ + private fun nudgePendingIntent(context: Context, weekday: Int? = null): PendingIntent { val intent = Intent(context, WindDownReceiver::class.java).setAction(ACTION_NUDGE) return PendingIntent.getBroadcast( - context, REQUEST_CODE, intent, + context, REQUEST_CODE + (weekday ?: 0), intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) } @@ -100,6 +137,43 @@ object WindDownScheduler { } } + /** + * Move a `Calendar.DAY_OF_WEEK` by [shift] days, wrapping through the week end (1=Sun…7=Sat). + * + * [shift] is normally 0 or -1 — an early wake puts its wind-down on the previous evening — but the + * arithmetic is general so a long sleep-need plus lead cannot produce an out-of-range weekday. + */ + internal fun shiftWeekday(weekday: Int, shift: Int): Int = + Math.floorMod(weekday - 1 + shift, 7) + 1 + + /** + * The next time [minuteOfDay] falls on [weekday] (Calendar 1=Sun…7=Sat) — the anchor for a weekly + * repeat. Walks forward at most seven days, so today counts only if the minute is still ahead. + * + * `internal` so the day/time arithmetic is unit-testable without an AlarmManager, which is the whole + * of what could go wrong here. + */ + internal fun nextWeeklyOccurrence( + minuteOfDay: Int, + weekday: Int, + now: Calendar = Calendar.getInstance(), + ): Calendar { + val cal = (now.clone() as Calendar).apply { + set(Calendar.HOUR_OF_DAY, minuteOfDay / 60) + set(Calendar.MINUTE, minuteOfDay % 60) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + var guard = 0 + while ((cal.get(Calendar.DAY_OF_WEEK) != weekday || cal.timeInMillis <= now.timeInMillis) && + guard < 8 + ) { + cal.add(Calendar.DAY_OF_YEAR, 1) + guard++ + } + return cal + } + private fun nextOccurrence(minuteOfDay: Int): Calendar = Calendar.getInstance().apply { set(Calendar.HOUR_OF_DAY, minuteOfDay / 60) diff --git a/android/app/src/main/java/com/noop/alarm/WindDownStore.kt b/android/app/src/main/java/com/noop/alarm/WindDownStore.kt index 804ba00141..330ec97933 100644 --- a/android/app/src/main/java/com/noop/alarm/WindDownStore.kt +++ b/android/app/src/main/java/com/noop/alarm/WindDownStore.kt @@ -28,9 +28,26 @@ class WindDownStore(private val prefs: SharedPreferences) { get() = prefs.getInt(KEY_LEAD, DEFAULT_LEAD).coerceIn(LEAD_MIN, LEAD_MAX) set(v) = prefs.edit().putInt(KEY_LEAD, v.coerceIn(LEAD_MIN, LEAD_MAX)).apply() + /** + * How many days BEFORE the wake day the nudge falls: 0 for the same day, -1 for the evening before. + * + * Only matters once the nudge is pinned to a WEEKDAY (#1858's per-day fan-out). The single daily + * schedule never needed it — it fires at a minute-of-day every day, so which day "owns" it is not a + * question anyone asks. + * + * It matters now because per-day wake times exist precisely to support EARLY wakes: a 03:30 wake with + * an 8 h need and a 30 min lead puts the nudge at 19:00 the PREVIOUS evening. Pinning that to the + * wake's own weekday would fire it eight hours AFTER the wake it is meant to precede — and it would be + * pointing at the next day's wake, which may be a different time entirely. + */ + fun nudgeDayShift(wakeMinutes: Int): Int = + nudgeDayShift(wakeMinutes, sleepNeedMinutes, leadMinutes) + /** * Minute-of-day the nudge should fire, derived from a wake time: wake − sleepNeed − lead, * wrapped into [0, 1440). With an 06:30 wake, 8 h need and 30 min lead this is 22:00. + * + * The wrap loses WHICH day that minute belongs to, which is why [nudgeDayShift] exists beside it. */ fun nudgeMinuteOfDay(wakeMinutes: Int): Int { val raw = wakeMinutes - sleepNeedMinutes - leadMinutes @@ -39,6 +56,10 @@ class WindDownStore(private val prefs: SharedPreferences) { } companion object { + /** Pure form of [nudgeDayShift], so the day arithmetic is testable without a Context. */ + fun nudgeDayShift(wakeMinutes: Int, sleepNeedMinutes: Int, leadMinutes: Int): Int = + Math.floorDiv(wakeMinutes - sleepNeedMinutes - leadMinutes, 24 * 60) + private const val PREFS = "noop_wind_down" private const val KEY_ENABLED = "windDown.enabled" private const val KEY_SLEEP_NEED = "windDown.sleepNeedMinutes" diff --git a/android/app/src/main/java/com/noop/analytics/AdaptiveExpenditureEngine.kt b/android/app/src/main/java/com/noop/analytics/AdaptiveExpenditureEngine.kt new file mode 100644 index 0000000000..a4562fac1e --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/AdaptiveExpenditureEngine.kt @@ -0,0 +1,158 @@ +package com.noop.analytics + +/** + * One day's inputs for the retrospective expenditure estimate. Both fields are nullable because the two + * series are independently sparse — a day with a weigh-in and no food log is common, and inventing a + * value for the missing one is exactly what this engine must not do. + */ +data class AdaptiveExpenditureDay( + val day: String, + val caloriesIn: Double? = null, + val weightKg: Double? = null, +) + +/** How much the estimate deserves to be trusted. Coarse on purpose — a percentage would imply a + * precision this method does not have. */ +enum class AdaptiveExpenditureConfidence { BUILDING, MODERATE, HIGH } + +/** + * A retrospective estimate of average daily energy expenditure, with an interval. + * + * Never a single number: the error is dominated by things this engine cannot see (hydration swings, an + * under-logged weekend), so a bare figure would be the fabrication the rest of NOOP refuses to make. + */ +data class AdaptiveExpenditureEstimate( + val estimatedDailyKcal: Double, + val lowerKcal: Double, + val upperKcal: Double, + val meanIntakeKcal: Double, + val weightSlopeKgPerDay: Double, + val intakeDays: Int, + val weightReadings: Int, + val windowDays: Int, + val confidence: AdaptiveExpenditureConfidence, +) + +/** + * Average daily expenditure inferred from logged intake and the weight trend (TDEE by energy balance). + * + * `expenditure = intake - change in stored energy`, with the conventional 7,700 kcal per kg of body + * mass. The identity is exact; the inputs are not. Day-to-day weight is mostly water, and food logs are + * under-reported by a wide and person-specific margin, so this is meaningful only over weeks and only as + * a range. + * + * DELIBERATELY NOT AN INPUT TO ANYTHING. It never feeds Charge, the calorie card, or the workout calorie + * estimate: those come from measured heart rate through Keytel, and quietly overwriting a measurement + * with an inference from a food diary would be a strictly worse number wearing the same label. + * + * Swift twin: `AdaptiveExpenditureEngine`. + */ +object AdaptiveExpenditureEngine { + /** The textbook 7,700 kcal/kg figure, which assumes the change is adipose; it over-states early loss, + * when a real share is glycogen and its bound water. That bias is one reason the output is a range. */ + const val KCAL_PER_KG = 7_700.0 + + const val MIN_WINDOW_DAYS = 21 + const val MAX_WINDOW_DAYS = 42 + const val MIN_INTAKE_DAYS = 14 + const val MIN_INTAKE_COVERAGE = 0.70 + const val MIN_WEIGHT_READINGS = 6 + + /** null when the history cannot support an estimate — the normal answer for most installs, and the + * point of the gates. [days] need not be sorted or contiguous. */ + fun estimate(days: List): AdaptiveExpenditureEstimate? { + val ordered = days.sortedBy { it.day } + val first = ordered.firstOrNull()?.day ?: return null + val last = ordered.last().day + val span = dayCount(first, last) ?: return null + if (span < MIN_WINDOW_DAYS) return null + val window = minOf(span, MAX_WINDOW_DAYS) + val recent = ordered.filter { d -> + val back = dayCount(d.day, last) ?: return@filter false + // `dayCount` is INCLUSIVE — `dayCount(last, last)` is 1 — so the last `window` days are + // `back <= window`. A strict `<` silently drops the oldest day while still reporting the full + // `window`, which both loses data and understates coverage. + back <= window + } + + val intake = recent.mapNotNull { it.caloriesIn }.filter { it > 0 } + val weights = recent.mapNotNull { d -> + val w = d.weightKg ?: return@mapNotNull null + if (w <= 0) return@mapNotNull null + val i = dayCount(first, d.day) ?: return@mapNotNull null + i to w + } + if (intake.size < MIN_INTAKE_DAYS) return null + // Clamped: `coverage` is "share of the window that was logged", so it cannot exceed 1. A caller + // that merged its two sparse series badly and passed a day twice would otherwise push it above 1, + // which SHRINKS the margin and RAISES the confidence — making the answer look more certain than + // its data, the one direction this engine must never err in. Clamping rather than de-duplicating + // on purpose: silently picking one of two conflicting values for a day would hide the caller's bug. + val coverage = minOf(1.0, intake.size.toDouble() / window.toDouble()) + if (coverage < MIN_INTAKE_COVERAGE) return null + // DISTINCT days, not readings. Two weigh-ins on one morning are one day of evidence about the + // trend, and counting both would let a chatty scale — or a caller that passed a day twice — buy + // the same confidence as a fortnight of extra data. + val weightDays = weights.map { it.first }.distinct().size + if (weightDays < MIN_WEIGHT_READINGS) return null + val slope = leastSquaresSlope(weights) ?: return null + + val meanIntake = intake.sum() / intake.size.toDouble() + // A RISING weight means intake exceeded expenditure, so the stored-energy term is SUBTRACTED. + // Getting this sign backwards is the classic error, which is why the tests pin both directions. + val estimate = meanIntake - slope * KCAL_PER_KG + + val waterKcalPerDay = (0.5 * KCAL_PER_KG) / window.toDouble() + val intakeUncertainty = meanIntake * 0.10 * (1.0 - coverage) + meanIntake * 0.05 + val margin = waterKcalPerDay + intakeUncertainty + + val confidence = when { + window >= 28 && coverage >= 0.90 && weightDays >= 12 -> AdaptiveExpenditureConfidence.HIGH + window >= 21 && coverage >= 0.80 -> AdaptiveExpenditureConfidence.MODERATE + else -> AdaptiveExpenditureConfidence.BUILDING + } + + return AdaptiveExpenditureEstimate( + estimatedDailyKcal = estimate, + lowerKcal = estimate - margin, + upperKcal = estimate + margin, + meanIntakeKcal = meanIntake, + weightSlopeKgPerDay = slope, + intakeDays = minOf(intake.size, window), + weightReadings = weightDays, + windowDays = window, + confidence = confidence, + ) + } + + /** OLS slope in kg per DAY. null when every reading shares one day, which would divide by zero — a + * real case when a scale syncs several readings with one timestamp. */ + internal fun leastSquaresSlope(points: List>): Double? { + val n = points.size.toDouble() + if (points.size < 2) return null + val meanX = points.sumOf { it.first.toDouble() } / n + val meanY = points.sumOf { it.second } / n + var num = 0.0 + var den = 0.0 + for ((x, y) in points) { + val dx = x.toDouble() - meanX + num += dx * (y - meanY) + den += dx * dx + } + return if (den > 0) num / den else null + } + + /** + * Whole days between two "yyyy-MM-dd" keys, inclusive of the first. + * + * null on an unparseable key. [AnalyticsEngine.dayStartUtcSeconds] is deliberately nil-tolerant and + * returns 0 for one, which is indistinguishable from 1970 here — and a stray 0 would silently stretch + * the window by twenty thousand days. + */ + internal fun dayCount(from: String, to: String): Int? { + val a = AnalyticsEngine.dayStartUtcSeconds(from) + val b = AnalyticsEngine.dayStartUtcSeconds(to) + if (a <= 0L || b <= 0L) return null + return ((b - a) / 86_400L).toInt() + 1 + } +} diff --git a/android/app/src/main/java/com/noop/analytics/AlarmReadback.kt b/android/app/src/main/java/com/noop/analytics/AlarmReadback.kt new file mode 100644 index 0000000000..a37707fb61 --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/AlarmReadback.kt @@ -0,0 +1,66 @@ +package com.noop.analytics + +/** + * Whether the alarm time a strap reports back can be compared with the one we sent (#1706). + * + * The two halves are persisted under flat, device-less keys — `alarm.lastArmSentEpoch` and + * `alarm.lastReportedEpoch`. On an install with more than one strap registered they can therefore + * describe DIFFERENT devices, and comparing them then produces a confident "strap didn't accept the + * time" about a strap that was never asked. On Android the readback is written only on the WHOOP 4.0 + * path, so a 5.0-active install could only ever be comparing across straps. + * + * That verdict is not cosmetic on Apple: it also drives `alarm.rejectStreak`, which raises a warning in + * SmartAlarmView at two. A cross-strap comparison could climb that streak forever. + * + * So attribution is required, not assumed: unless both halves are known to come from the same strap, + * this refuses to judge. Refusing is the same stance `WindowedStreamPlan` takes — a diagnosis that + * cannot be proven is worse than none, because it sends the reader after the wrong device. + * + * Twin of Swift `AlarmReadback`. + */ +object AlarmReadback { + + /** Seconds of slack allowed between what we armed and what the strap reports back. */ + const val TOLERANCE_S: Long = 120 + + enum class Verdict { + /** Same strap, and the readback agrees within [TOLERANCE_S]. */ + MATCHES, + /** Same strap, and it does not. This is the only value that means the strap refused. */ + MISMATCH, + /** The two halves came from different straps. Nothing can be concluded about either. */ + DIFFERENT_STRAP, + /** One or both halves predate device attribution, so they cannot be tied to a strap. */ + UNATTRIBUTED, + } + + fun verdict( + sentEpoch: Long, + reportedEpoch: Long, + sentDeviceId: String?, + reportedDeviceId: String?, + toleranceS: Long = TOLERANCE_S, + ): Verdict { + if (sentDeviceId.isNullOrBlank() || reportedDeviceId.isNullOrBlank()) return Verdict.UNATTRIBUTED + if (sentDeviceId != reportedDeviceId) return Verdict.DIFFERENT_STRAP + return if (kotlin.math.abs(reportedEpoch - sentEpoch) > toleranceS) Verdict.MISMATCH else Verdict.MATCHES + } + + /** The suffix the debug export appends after the reported time. Byte-identical to the Swift twin. */ + fun suffix(verdict: Verdict): String = when (verdict) { + Verdict.MATCHES -> " ✓ matches" + Verdict.MISMATCH -> " ⚠️ MISMATCH — strap didn't accept the time" + Verdict.DIFFERENT_STRAP -> " (readback is from a different strap — not comparable)" + Verdict.UNATTRIBUTED -> " (no strap recorded for one of these — not comparable)" + } + + /** + * Whether this verdict may advance the consecutive-rejection streak. Only a proven same-strap + * disagreement counts: an unattributed or cross-strap reading must leave the streak untouched + * rather than reset it, since neither is evidence either way. + */ + fun countsAsRejection(verdict: Verdict): Boolean = verdict == Verdict.MISMATCH + + /** Whether this verdict is evidence the strap DID accept, which clears the streak. */ + fun clearsRejectionStreak(verdict: Verdict): Boolean = verdict == Verdict.MATCHES +} diff --git a/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt b/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt index b6e09d6d7b..ae8a268388 100644 --- a/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt @@ -138,6 +138,13 @@ object AnalyticsEngine { * back to 0 — an empty 1970 window no real sample matches — rather than throwing, so a single bad key can * never take down a whole scoring pass. Byte-identical twin of the Swift `AnalyticsEngine.dayStartUtcSeconds` * (locked cross-platform by AnalyticsEngineDayBoundsTest / AnalyticsEngineDayBoundsTests). + * + * This is a UTC midnight that callers pair with one captured offset and fixed 86,400-second blocks, so + * every boundary beyond a clock change sits an hour from the local midnight it names. [LocalDayWindows] + * is the zone-rule-correct primitive built to replace that, and is deliberately called by nothing yet. + * This function remains the shipped answer until a switch-over lands; the two disagree by an hour on + * the far side of a transition, and `LocalDayWindowsTest` pins both answers so the difference is + * visible rather than discovered. */ fun dayStartUtcSeconds(day: String): Long = runCatching { LocalDate.parse(day).atStartOfDay(ZoneOffset.UTC).toEpochSecond() }.getOrDefault(0L) @@ -224,19 +231,6 @@ object AnalyticsEngine { ) } - /** - * Analyze one day's streams into a [DayResult]. - * - * @param day the calendar day (UTC) this metric is for; a sleep session is - * attributed to the day its `end` falls on (a night ending that morning). - * @param hr/rr/resp/gravity the day's raw streams (the wider window around the - * night may be passed; sleep detection finds the in-bed span itself). - * @param profile user profile (age/sex/weight/height) for HRmax + calories. - * @param baselines personal baselines for recovery normalization. - * @param maxHROverride explicit HRmax (bpm) to use for strain/zones; null → - * Tanaka from profile.age. - */ - @Suppress("UNUSED_PARAMETER") // sleepNeedNights kept for signature stability (unused in the current body) /** * Minimum span (seconds) a night's vendor respiration rows must cover before their median is taken * as the night's rate. One hour: enough that the value describes the night rather than a fragment, @@ -272,8 +266,23 @@ object AnalyticsEngine { return if (median in SleepStager.respPlausibleRangeBpm) median else null } + /** + * Analyze one day's streams into a [DayResult]. + * + * @param day the calendar day (UTC) this metric is for; a sleep session is + * attributed to the day its `end` falls on (a night ending that morning). + * @param hr/rr/resp/gravity the day's raw streams (the wider window around the + * night may be passed; sleep detection finds the in-bed span itself). + * @param profile user profile (age/sex/weight/height) for HRmax + calories. + * @param baselines personal baselines for recovery normalization. + * @param maxHROverride explicit HRmax (bpm) to use for strain/zones; null → + * Tanaka from profile.age. + */ fun analyzeDay( day: String, + // Optional sink for the Effort funnel line. Null (the default) builds nothing at all — see + // StrainScorer.strain. Kept a parameter rather than a field so this engine stays pure. + strainDiag: ((String) -> Unit)? = null, hr: List = emptyList(), rr: List = emptyList(), resp: List = emptyList(), @@ -341,9 +350,6 @@ object AnalyticsEngine { // Personal sleep need (hours) for the Rest "duration vs need" component. null → 8 h default. // IntelligenceEngine refines it from the user's recent average asleep hours. (Charge/Effort/Rest) sleepNeedHours: Double? = null, - // How many recent nights informed [sleepNeedHours] (0 = still on the 8 h default). Drives the - // Rest confidence tier ONLY; does not affect the score. (Charge/Effort/Rest) - sleepNeedNights: Int = 0, // Sleep/wake regularity in [0,1] (1 = perfectly regular) for the Rest "consistency" component. // null (single-day / pure callers with no history) → the term drops and its weight // renormalizes, exactly like the recovery driver-drop discipline. (Charge/Effort/Rest) @@ -444,6 +450,11 @@ object AnalyticsEngine { } else { val rrSorted = rr.sortedBy { it.ts } val enrichedProvided = providedSleep.map { s -> + // #1884: no HR-only special case any more. This used to short-circuit on `s.hrOnly` to + // preserve #1801's display-only guarantee, which withheld both values. Now that an HR-only + // session reports what it measured, that clause is not merely redundant — an HR-only night + // that measured a resting HR but no HRV (no R-R banked) would take the short-circuit and + // skip the fill every other session gets. The rule is uniform: fill what is missing. if (s.restingHR != null && s.avgHRV != null) s else s.copy( restingHR = s.restingHR ?: SleepStager.sessionRestingHR(s.start, s.end, hr), @@ -495,11 +506,30 @@ object AnalyticsEngine { var inBedS = 0.0 var effWeighted = 0.0 var disturbances = 0 + // Hypnogram COVERAGE across the group: how much of the fragments' own spans the stage segments + // actually account for. Accumulated separately from `inBedS` because that one later absorbs the + // inter-fragment gap (#777/#705), which is a different quantity — a bridged out-of-bed gap is + // known-awake time between two fragments, whereas a hole INSIDE a fragment is time we never + // observed at all. Summed straight off `s.stages` (no JSON re-parse: the segments are already + // decoded here), then handed to HypnogramCoverage so the ratio/clamp/null rules have ONE + // definition shared with the merge-side guard. Mirrors Swift. + // NOTE on scope at THIS call site: coverage here is summed off the DECODED segments, not off + // `stagesJSON`, so the timestamp-free shapes are not screened out by the payload-shape rule the + // merge side uses. A minute-dict session decodes to zero segments and contributes span with no + // cover, and what keeps it from reading as a holed night is `fraction`'s `coveredSeconds > 0` + // returning nil for a group with no timestamped stages at all. Same outcome, different + // mechanism — and it holds only while a group is single-sourced, which the day merge ensures by + // choosing one side. A group mixing a timestamped fragment with a minute-dict one would read as + // holed; `HypnogramCoverageTest.mixedSourceGroupReadsAsHoled` pins both halves. + var coveredS = 0.0 + var spanS = 0.0 for (s in mainGroup) { val m = SleepStager.hypnogramMetrics(s) val inBed = (s.end - s.start).toDouble() inBedS += inBed // each fragment's own in-bed span (the gap is added below) effWeighted += s.efficiency * inBed // in-bed-weighted efficiency across the group + spanS += inBed + for (seg in s.stages) if (seg.end > seg.start) coveredS += (seg.end - seg.start).toDouble() deepS += m.deepMin * 60.0 remS += m.remMin * 60.0 lightS += m.lightMin * 60.0 @@ -529,7 +559,24 @@ object AnalyticsEngine { // negligible shift. The Rest/sleep-quality term is main-night; the recovery physiology is // day-best-resting, night-dominated. Mirrors the Swift note in AnalyticsEngine.swift. // Daily resting HR = lowest per-session resting HR across matched sessions. - val restingHRDaily: Int? = matched.mapNotNull { it.restingHR }.minOrNull() + // #1801/#1884: the sessions whose PHYSIOLOGY is folded into the day's aggregates. Motion-backed + // sessions are PREFERRED; an HR-only night is used only when the day has no other kind. + // + // #1801 excluded HR-only nights outright, reasoning that a baseline is the one thing a false positive + // cannot be unwound from. #1884 narrowed that rather than reversing it: only the session BOUNDS are + // inferred from heart rate — each RMSSD is measured over its own 5-minute window — so excluding the + // night discarded a real 22-25ms HRV and left Charge with NO input instead of a slightly fuzzy one, on + // every scoring pass in the field log. Preferring keeps the original protection exactly where it earned its keep (a mixed + // day still ignores the HR-only night outright) and gives it up only where the alternative was + // nothing at all. The night still travels marked `hrOnly` for any consumer that wants to weigh it + // down; what it no longer gets is a silent delete. + // + // Named once rather than filtered at each use: the deep-window HRV pool and the SDNN index below + // re-derive from `rr` over each session's own stages instead of reading restingHR/avgHRV, so the + // only way to scope them is through the session set itself — which is precisely the "one forgotten + // call site" a scattered filter invites. + val physiologySessions = matched.filter { !it.hrOnly }.ifEmpty { matched } + val restingHRDaily: Int? = physiologySessions.mapNotNull { it.restingHR }.minOrNull() // Daily avg HRV = in-bed-weighted mean of per-session avg HRV. val avgHRVDaily: Double? = if (deepHrvWindow) { // #141: WHOOP-style HRV — pool RMSSD over DEEP-stage 5-min windows only (slow-wave sleep), @@ -538,13 +585,13 @@ object AnalyticsEngine { // (RMSSD = successive diffs). null when the night has no detected deep sleep (WHOOP-4.0 staging // can be sparse) — the caller then shows calibrating, never a fabricated number. val rrSorted = rr.sortedBy { it.ts } - val deep = matched.flatMap { s -> + val deep = physiologySessions.flatMap { s -> SleepStager.sessionHrvWindows(s.start, s.end, rrSorted, s.stages) .filter { it.stage == "deep" }.mapNotNull { it.rmssd } } if (deep.isEmpty()) null else deep.sum() / deep.size } else run { - val pairs = matched.mapNotNull { s -> + val pairs = physiologySessions.mapNotNull { s -> s.avgHRV?.let { it to (s.end - s.start).toDouble() } } if (pairs.isEmpty()) { @@ -560,7 +607,7 @@ object AnalyticsEngine { // timestamps needed for segmentation and stays distinct from avgHrv (RMSSD). The half-open sleep // bounds match every other in-bed aggregate; no qualifying 20-clean-beat segment means null. val avgSDNNDaily = HrvAnalyzer.sdnnIndex( - rr.filter { sample -> matched.any { sample.ts >= it.start && sample.ts < it.end } }, + rr.filter { sample -> physiologySessions.any { sample.ts >= it.start && sample.ts < it.end } }, segmentSec = 300, ) @@ -732,6 +779,11 @@ object AnalyticsEngine { restingHR = restForStrain, method = effortMethod, sex = profile.sex, + // The Effort ring's own funnel. Null sink = no line built, so a caller that does not want + // diagnostics pays nothing; IntelligenceEngine passes its per-day recorder, the same one the + // `workout detect` and `sleep-detect` lines beside it already use. + diag = strainDiag, + day = day, ) // ── Workouts ────────────────────────────────────────────────────────── @@ -833,6 +885,15 @@ object AnalyticsEngine { disturbances = if (matched.isEmpty()) null else disturbances, restingHr = restingHRDaily, avgHrv = avgHRVDaily, + // "Every session this day was staged from heart rate alone." + // + // #1884: read from the SESSIONS' own marker, NOT from `physiologySessions.isEmpty()`. Those + // two were equivalent while the set was `matched` minus the HR-only ones, so an all-HR-only + // night emptied it. They are NOT equivalent now that the set FALLS BACK to `matched`: it can + // never be empty when `matched` is not, which would have pinned this flag to false forever + // and silently retired the #1879 note. Deriving it from `hrOnly` states what the flag has + // always meant and is independent of how the physiology set is chosen. + sleepHrOnly = if (matched.isEmpty()) null else matched.all { it.hrOnly }, recovery = recovery, strain = strain, exerciseCount = workouts.size, @@ -856,8 +917,11 @@ object AnalyticsEngine { // Rest confidence with H9 + the #345 sparse-motion guard: downgrade to low-confidence a night whose // deep+REM share is implausibly low on a high-efficiency night (H9 staging miss) OR that was staged // on sparse gravity (WHOOP 4.0 coarse-banked motion can't reliably stage sleep — a confident 85–100 - // Rest is unearned however the engine filled it). Confidence-only, no faked stages. tstS/efficiency - // are the main-group totals above; restorative = deepS + remS. Mirrors Swift. + // Rest is unearned however the engine filled it). `stageCoverage` adds the third guard: a night + // whose stage timeline covers only part of its own span (an incompletely-received device + // hypnogram) cannot earn a SOLID Rest either, and neither of the other two can see it. + // Confidence-only, no faked stages. tstS/efficiency are the main-group totals above; + // restorative = deepS + remS. Mirrors Swift. val restConfidence = ScoreConfidence.forRest( hasSession = matched.isNotEmpty(), hasStagedSleep = (deepS + remS) > 0, @@ -865,6 +929,7 @@ object AnalyticsEngine { restorativeSeconds = deepS + remS, efficiency = efficiency, gravitySparse = gravitySparse, + stageCoverage = HypnogramCoverage.fraction(coveredS, spanS), ) // ── Per-session per-epoch motion (H8) ───────────────────────────────── @@ -1459,13 +1524,6 @@ object RestScorer { return (weighted * 100.0).roundToInt() / 100.0 } - /** - * Sleep & Rest test-mode (E11) diagnostic line for the Rest composite. Recomputes the four weighted - * sub-scores from the SAME inputs `rest()` reads (on the 0..1 scale, byte-aligned with the Swift - * `Rest.subScoreLine`), and reuses `rest()` for the final `composite=` value so the trace can never - * disagree with the score. `groupFragments` / `groupInBedSeconds` describe the main-night GROUP - * composition (#525/#561). Pure, side-effect-free, no em-dashes. Mirrors Swift exactly. - */ /** * #319 diagnostic (Sleep & Rest test mode): the motion-coverage + staging context behind the Rest * number, so a high score on a poor night can be explained straight from an export. `grav`/`hr` are the @@ -1510,6 +1568,13 @@ object RestScorer { return "sleep-onset onsetTs=$onsetTs hrAtOnset=$hrAtOnsetBpm baselineHr=$baselineHrBpm hrRatio=$r2" } + /** + * Sleep & Rest test-mode (E11) diagnostic line for the Rest composite. Recomputes the four weighted + * sub-scores from the SAME inputs `rest()` reads (on the 0..1 scale, byte-aligned with the Swift + * `Rest.subScoreLine`), and reuses `rest()` for the final `composite=` value so the trace can never + * disagree with the score. `groupFragments` / `groupInBedSeconds` describe the main-night GROUP + * composition (#525/#561). Pure, side-effect-free, no em-dashes. Mirrors Swift exactly. + */ @Suppress("UNUSED_PARAMETER") // inBedSeconds mirrors the Swift subScoreLine signature (parity) fun subScoreLine( tstSeconds: Double, inBedSeconds: Double, efficiency: Double, restorativeSeconds: Double, diff --git a/android/app/src/main/java/com/noop/analytics/AnalyticsModels.kt b/android/app/src/main/java/com/noop/analytics/AnalyticsModels.kt index 04c4587414..f3d7fa5927 100644 --- a/android/app/src/main/java/com/noop/analytics/AnalyticsModels.kt +++ b/android/app/src/main/java/com/noop/analytics/AnalyticsModels.kt @@ -87,6 +87,16 @@ data class DetectedSleep( val restingHR: Int?, /** Mean RMSSD over 5-min windows across the session (ms), or null. */ val avgHRV: Double?, + /** + * Staged WITHOUT a motion spine, from heart rate alone (#1801). + * + * True only for a strap that streams HR but banks no motion, where Stage 0's gravity-stillness spine + * has nothing to work with. Such a night is weaker by construction, not by tuning: with motion gone a + * quiet evening at rest can sit in the sleep band. It is allowed to describe itself — duration, + * stages, Rest — and must NOT reach anything it cannot be unwound from, which is why + * [restingHR] and [avgHRV] are left null on one rather than filtered out downstream. + */ + val hrOnly: Boolean = false, ) /** diff --git a/android/app/src/main/java/com/noop/analytics/AnalyzeRecentDayCache.kt b/android/app/src/main/java/com/noop/analytics/AnalyzeRecentDayCache.kt index 6131ec6003..287fee57ba 100644 --- a/android/app/src/main/java/com/noop/analytics/AnalyzeRecentDayCache.kt +++ b/android/app/src/main/java/com/noop/analytics/AnalyzeRecentDayCache.kt @@ -35,6 +35,15 @@ object AnalyzeRecentDayCache { * a 4.0 and a 5/MG): when a day's resolved owner flips between straps, keying on the owner makes the * reuse invalidate **explicitly**, rather than relying on two different devices never producing an * identical `count`+`maxTs` for the same window. + * - [streams]: the opaque per-day witness of every OTHER scored stream — PPG-derived HR, R-R, + * respiration, SpO2, gravity, steps, skin temp and events (`WhoopRepository.dayStreamFingerprint`). + * #29: a history offload does not commit its channels together, and an offloaded HR row duplicating a + * live one is ignored on conflict, so a night can be scored from HR alone and then gain its R-R with + * [hrCount]/[hrMaxTs] completely unmoved. Keyed on HR alone this said "reuse", and the HRV-less scan + * was re-served for the rest of the process, a user-initiated refresh included, since a forced pass + * only bypasses the whole-pass watermark gate. That gate had already been widened to every stream + * (`analysisFingerprint`, v2); this is the same widening at day granularity, which is where the reuse + * decision is actually made. * * Inputs that feed `analyzeDay` but are pass-global rather than per-day (profile, baselines1, sleep need * / consistency, habitual midsleep, tz, stager toggles) are NOT in this key — the engine drops the whole @@ -42,6 +51,7 @@ object AnalyzeRecentDayCache { */ fun cacheKey( owner: String, hrCount: Int, hrMaxTs: Long, skinAnchorRaw: Double?, + streams: String, // #1575: whether this day is the one that emits the PER-WINDOW HRV detail (`dayStart == // nowLocalMidnight`). Now that trace lines are recorded and replayed, this has to invalidate: // the night cached as "today" with its detailed trace becomes an ordinary night after midnight, @@ -52,6 +62,6 @@ object AnalyzeRecentDayCache { hrvWindowDetail: Boolean, ): String { val anchor = skinAnchorRaw?.toRawBits()?.toString() ?: "nil" - return "$owner|$hrCount:$hrMaxTs:$anchor:${if (hrvWindowDetail) "d" else "s"}" + return "$owner|$hrCount:$hrMaxTs:$anchor:${if (hrvWindowDetail) "d" else "s"}|$streams" } } diff --git a/android/app/src/main/java/com/noop/analytics/Baselines.kt b/android/app/src/main/java/com/noop/analytics/Baselines.kt index 6273645434..4b2c7b6e7e 100644 --- a/android/app/src/main/java/com/noop/analytics/Baselines.kt +++ b/android/app/src/main/java/com/noop/analytics/Baselines.kt @@ -234,6 +234,42 @@ object Baselines { return BaselineStatus.TRUSTED } + /** Named rather than a `Pair`: the two numbers are both Ints with the same units, so `first`/`second` + * at a call site is a coin toss that no test would catch — "5 of the last 3 nights" is absurd to a + * reader and invisible to a compiler. Mirrors the Swift twin's `(observed:missing:)` labels. */ + data class HrvCoverage(val observed: Int, val missing: Int) + + /** + * Nights the app OBSERVED in the recent window, and how many carried no usable HRV. Swift twin: + * `Baselines.recentHrvCoverage`. + * + * Distinct from both neighbours, and the gap between them. `calibrationNights` counts progress toward + * a baseline; [nightsSinceNewestValidNight] measures a TOTAL drought and only speaks once it passes + * [staleDays]. Neither says anything about the common case: nights arriving, but most of them carrying + * nothing. A wearer five days in with three empty nights sits at "2 of 4" with no reason given, which + * reads as a stuck counter rather than as missing data — that is the report this exists to answer. + * + * Counts only days the app has a row for, so a new install is not charged for nights before it owned + * the strap. Pure and TZ-free (civil-day arithmetic). + */ + fun recentHrvCoverage( + dayKeys: List, + nightlyHrv: List, + today: String, + window: Int = staleDays, + ): HrvCoverage { + val t = isoEpochDay(today) ?: return HrvCoverage(0, 0) + var observed = 0 + var missing = 0 + for (i in 0 until minOf(dayKeys.size, nightlyHrv.size)) { + val d = isoEpochDay(dayKeys[i]) ?: continue + if (t - d < 0 || t - d >= window) continue + observed++ + if (nightlyHrv[i] == null) missing++ + } + return HrvCoverage(observed, missing) + } + /** #612: calendar days since the newest night that carried a usable HRV reading (the baseline's input), * or null when there is none / a key can't be parsed. DISTINCT from `calibrationNights` (which counts * progress TOWARD a usable baseline) — this measures staleness, so a surface can say "no new nights from diff --git a/android/app/src/main/java/com/noop/analytics/CircadianEngine.kt b/android/app/src/main/java/com/noop/analytics/CircadianEngine.kt index 1b3a5f6255..73eaab83cb 100644 --- a/android/app/src/main/java/com/noop/analytics/CircadianEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/CircadianEngine.kt @@ -31,13 +31,53 @@ object CircadianEngine { * #982 — RELATIVE, applied to mean HR (mesor ~45-75 bpm), not to a near-zero-mesor motion volume. The * effective bar is `0.10 x mesor`: ~6.5 bpm at a 65 bpm mesor, ~4.5 bpm at 45. It scales WITH the * mesor, so a low-resting wearer faces a LOWER absolute bar — the opposite of the concern in #982. - * Deliberately NOT re-tuned: every candidate value is unvalidated and nobody is currently silenced. + * The VALUE is still not re-tuned — every candidate remains unvalidated. What changed is the SHAPE: + * [minAbsoluteAmplitudeBpm] now passes a swing that is large enough in bpm regardless of mesor, + * because a measured wearer showed the relative test refusing a coherent rhythm purely for having a + * high baseline. "Nobody is currently silenced by it" was true when written and is no longer. * `CircadianEngineTest` pins what it costs at each mesor. Mirrors the Swift twin exactly. */ const val minRelativeAmplitude: Double = 0.10 + /** + * Absolute amplitude (bpm) that reads as rhythmic whatever the mesor — the relative bar's escape hatch. + * + * The relative test scales the requirement WITH resting HR, so the identical swing is accepted on one + * body and refused on another: 5.5 bpm passes at a 55 bpm mesor and fails at 74.7. That is not a + * judgement about the rhythm, it is a judgement about the baseline, and a measured wearer sat exactly + * there — 5.5 bpm on a 74.7 bpm mesor, acrophase 16.1 h implying a CBTmin near 04:06, a textbook phase + * rather than noise. [minRelativeAmplitude]'s own note called for precisely that observation ("a + * wearer whose amplitude is disproportionately small for their mesor") before the shape could be + * revisited. + * + * 4.5 bpm is NOT a new tuning constant: it is the absolute amplitude the relative gate ALREADY accepts + * at the bottom of the ~45-75 bpm mesor range it was described against. The rule this encodes is + * internal consistency — an amplitude good enough for some wearer is good enough for all — so the + * change is strictly more permissive and no one who reads rhythmic today stops. + * Mirrors the Swift twin exactly. + */ + const val minAbsoluteAmplitudeBpm: Double = 4.5 const val maxShiftPerDayHours: Double = 1.0 const val cbtMinBeforeWakeHours: Double = 2.5 const val acrophaseAfterCbtMinHours: Double = 12.0 + /** + * Population-typical wake hour, used ONLY to place the chronotype reference point. + * + * [PhaseEstimate.offsetVsScheduleMinutes] compares the clock to the USER'S OWN schedule, so it cannot + * name a chronotype: someone reliably asleep 03:00-11:00 has an offset near zero and would read + * "intermediate" while being strongly evening-type. A named lean needs an ABSOLUTE phase, so it is + * bucketed from [PhaseEstimate.tempMinHour] against a population reference instead. + * Mirrors the Swift twin exactly. + */ + const val chronotypeReferenceWakeHour: Double = 7.0 + /** + * Half-width of the "intermediate" band around the reference CBTmin, in hours. + * + * Deliberately wide. `tempMinHour` is derived from an activity cosinor (`acrophase - 12 h`) unless a + * measured `observedTempMinHour` is supplied, and NO production caller supplies one today — so this is + * a lean inferred from movement, not a thermal measurement. A one-hour band either side keeps the + * three buckets coarse enough to be honest about that. Mirrors the Swift twin exactly. + */ + const val chronotypeBandHours: Double = 1.0 // ── Inputs ── @@ -133,7 +173,11 @@ object CircadianEngine { val fit = cosinor(bins) ?: return null val relativeAmplitude = if (fit.mesor != 0.0) fit.amplitude / abs(fit.mesor) else 0.0 - if (daysObserved < minDaysForFit || relativeAmplitude < minRelativeAmplitude) { + // Rhythmic on EITHER measure: a proportional swing, or an absolute one large enough to read on any + // baseline. See [minAbsoluteAmplitudeBpm] for why the relative test alone was self-inconsistent. + val rhythmic = + relativeAmplitude >= minRelativeAmplitude || fit.amplitude >= minAbsoluteAmplitudeBpm + if (daysObserved < minDaysForFit || !rhythmic) { val tmin = observedTempMinHour ?: wrap24(fit.acrophaseHours - acrophaseAfterCbtMinHours) return PhaseEstimate(tmin, fit.acrophaseHours, 0.0, PhaseConfidence.UNREADABLE, "Your rhythm is hard to read right now - keep wearing it for a clearer picture.") @@ -146,7 +190,16 @@ object CircadianEngine { val offsetHours = signedHourDelta(idealTempMin, tempMinHour) val offsetMinutes = offsetHours * 60.0 - val confidence = if (daysObserved >= goodDaysForFit) PhaseConfidence.SOLID else PhaseConfidence.WIDE + // SOLID means strong on BOTH axes, not just enough days. A rhythm admitted by + // [minAbsoluteAmplitudeBpm] alone is real but modest, and a smaller swing pins its acrophase less + // tightly — so it stays WIDE. That matters because WIDE is what withholds [chronotype], which + // names a category off exactly that acrophase: widening the readable gate should give more people + // a body clock, not give a thinner fit a firmer label. + val confidence = if (daysObserved >= goodDaysForFit && relativeAmplitude >= minRelativeAmplitude) { + PhaseConfidence.SOLID + } else { + PhaseConfidence.WIDE + } val lean = when { offsetMinutes > 20 -> "later (a night-owl lean)" offsetMinutes < -20 -> "earlier (a morning-lark lean)" @@ -245,6 +298,99 @@ object CircadianEngine { return x } + /** + * A coarse, ABSOLUTE body-clock category: where the temperature minimum sits on the clock. + * + * NOT the "chronotype lean" wording used elsewhere. [estimatePhase] builds a `lean` string from + * [PhaseEstimate.offsetVsScheduleMinutes] ("a night-owl lean"), and the v5 skin-temp design spec + * defines chronotype lean as "earlier/later than your sleep schedule implies" — both RELATIVE to the + * wearer's own schedule. This is relative to the CLOCK, and the two genuinely disagree: a consistent + * 03:00-11:00 sleeper is well-aligned by the relative read and EVENING by this one. Keep the + * vocabularies disjoint in anything user-facing, or the two readings look like a contradiction (#1409). + * + * Three buckets, not a score: the underlying phase estimate is an activity fit, and a finer grain + * would imply precision it does not have. Twin of Swift `CircadianEngine.Chronotype`. + */ + enum class Chronotype(val raw: String) { + MORNING("morning"), // CBTmin earlier than the population reference + INTERMEDIATE("intermediate"), + EVENING("evening"), // CBTmin later than the population reference + } + + /** + * The reference CBTmin clock hour a lean is measured against: the population wake hour minus the same + * [cbtMinBeforeWakeHours] the phase estimator already uses, so the anchor moves with the engine's own + * model rather than being a second, independently-drifting constant. + */ + val chronotypeAnchorHour: Double get() = wrap24(chronotypeReferenceWakeHour - cbtMinBeforeWakeHours) + + /** A chronotype-ideal sleep window on the 24 h clock. Twin of the Swift tuple return. */ + data class IdealSleepWindow(val bedHour: Double, val wakeHour: Double) + + /** + * The chronotype-ideal sleep window for a night of [durationHours], as clock hours. + * + * Anchored on the temperature minimum: a well-entrained sleeper wakes about [cbtMinBeforeWakeHours] + * after CBTmin, so the ideal wake is `tempMinHour + cbtMinBeforeWakeHours` and the ideal bedtime is + * that minus the night's own length. + * + * USING THE ACTUAL DURATION IS THE POINT. Giving the ideal arc the same length as the real one makes + * the comparison purely about PHASE — did you sleep at the right TIME — so a short night reads as + * aligned-but-short rather than as misaligned. Feeding a "needed" duration instead would fold two + * different failures into one arc and make a debt look like a body-clock problem. + * + * null for a non-positive or impossible duration; a window longer than a day cannot be placed on a + * 24 h ring, and silently wrapping it would draw a full circle that means nothing. + * Mirrors the Swift twin exactly. + */ + fun idealSleepWindow(tempMinHour: Double, durationHours: Double): IdealSleepWindow? { + if (durationHours <= 0.0 || durationHours >= 24.0) return null + val wake = wrap24(tempMinHour + cbtMinBeforeWakeHours) + return IdealSleepWindow(bedHour = wrap24(wake - durationHours), wakeHour = wake) + } + + /** + * Signed hours the ACTUAL sleep window sits later (+) or earlier (−) than the chronotype-ideal one. + * + * Deliberately NOT [PhaseEstimate.offsetVsScheduleMinutes]. That field compares the body clock to the + * wearer's own SCHEDULE; this compares the night actually slept to where the CLOCK wanted it. A dial + * that draws an actual arc against an ideal arc must caption itself with the distance between those + * two arcs, or the number contradicts the picture — the two disagree exactly when someone keeps a + * consistent schedule that does not suit their clock, which is the case the dial exists to show. + * + * Anchored on wake rather than bedtime because [idealSleepWindow] builds the ideal window from the + * wake end; comparing bedtimes would fold the night's DURATION into a phase reading. + * Mirrors the Swift twin exactly. + */ + fun sleepWindowOffsetHours(tempMinHour: Double, actualWakeHour: Double): Double = + signedHourDelta(wrap24(tempMinHour + cbtMinBeforeWakeHours), wrap24(actualWakeHour)) + + /** + * Bucket an ABSOLUTE temperature-minimum clock hour into a lean. + * + * Compared CIRCULARLY. A [tempMinHour] of 23:30 is five hours BEFORE the 04:30 anchor — a strong + * morning lean — and a naive `23.5 > 5.5` would call it evening instead. Pure, so the boundaries are + * assertable without building a fit. + */ + fun chronotype(tempMinHour: Double): Chronotype { + val delta = signedHourDelta(chronotypeAnchorHour, wrap24(tempMinHour)) + if (delta < -chronotypeBandHours) return Chronotype.MORNING + if (delta > chronotypeBandHours) return Chronotype.EVENING + return Chronotype.INTERMEDIATE + } + + /** + * The lean for a phase estimate, or null when the fit is not strong enough to name one. + * + * Gated to [PhaseConfidence.SOLID] on purpose. WIDE is a real fit on thin data and is fine for the + * continuous offset the card already shows, but a NAMED category reads as a fact about the person + * rather than a reading of the week, so it waits for the stronger tier. UNREADABLE never names one. + */ + fun chronotype(estimate: PhaseEstimate): Chronotype? { + if (estimate.confidence != PhaseConfidence.SOLID) return null + return chronotype(estimate.tempMinHour) + } + /** Signed shortest delta in hours from [a] to [b] on the 24 h clock, in (−12, 12]. */ internal fun signedHourDelta(a: Double, b: Double): Double { var d = (b - a) % 24.0 diff --git a/android/app/src/main/java/com/noop/analytics/ClockFormat.kt b/android/app/src/main/java/com/noop/analytics/ClockFormat.kt new file mode 100644 index 0000000000..821f524c1c --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/ClockFormat.kt @@ -0,0 +1,65 @@ +package com.noop.analytics + +/** + * #1821: which clock the UI shows times in. Twin of the Apple `ClockFormatPreference`. + * + * NOOP had no such setting: every user-facing time came from the device region's convention, and on + * Apple the region identifier discards the user's explicit "24-Hour Time" switch entirely. A reader in a + * 24-hour region who prefers 12-hour had no way to say so, which is the report this exists to answer. + */ +enum class ClockFormatPreference { + /** Follow the device's own clock switch. The default, so nobody's display changes on upgrade. */ + SYSTEM, + TWELVE_HOUR, + TWENTY_FOUR_HOUR, + ; + + companion object { + /** Persistence key, shared with the Apple @AppStorage binding so the two cannot drift apart. */ + const val PREFS_KEY = "clockFormatPreference" + + /** Stored as the Apple rawValue strings, so one documented vocabulary spans both platforms. */ + fun from(stored: String?): ClockFormatPreference = when (stored) { + "twelveHour" -> TWELVE_HOUR + "twentyFourHour" -> TWENTY_FOUR_HOUR + // Unknown/absent must not silently pin every reader to one clock. + else -> SYSTEM + } + } + + /** The value written to storage - the Apple rawValue, not the Kotlin enum name. */ + fun stored(): String = when (this) { + SYSTEM -> "system" + TWELVE_HOUR -> "twelveHour" + TWENTY_FOUR_HOUR -> "twentyFourHour" + } +} + +object ClockFormat { + /** + * Resolve the preference against what the device reports. [systemUses24Hour] is injected rather than + * read here so this stays pure and identically testable on both platforms - Android supplies + * `DateFormat.is24HourFormat(context)`, Apple `Locale.autoupdatingCurrent`. + */ + fun uses24Hour(preference: ClockFormatPreference, systemUses24Hour: Boolean): Boolean = + when (preference) { + ClockFormatPreference.SYSTEM -> systemUses24Hour + ClockFormatPreference.TWELVE_HOUR -> false + ClockFormatPreference.TWENTY_FOUR_HOUR -> true + } + + /** + * The SimpleDateFormat pattern for a wall-clock time at minute precision. Explicit patterns, not a + * localised template: once the reader has CHOSEN a clock, a template would hand the decision back to + * the locale and quietly ignore them. + */ + fun hourMinutePattern(uses24Hour: Boolean): String = if (uses24Hour) "HH:mm" else "h:mm a" + + /** + * SKELETON template for the same time, the twin of the Apple accessor. Apple feeds this to + * `setLocalizedDateFormatFromTemplate` so the locale still decides ordering and AM/PM wording while + * the H-vs-h choice stays ours. Deliberately NOT `j`, which resolves the hour from the locale - the + * precise mechanism by which a reader's explicit preference was being discarded. + */ + fun hourMinuteTemplate(uses24Hour: Boolean): String = if (uses24Hour) "Hmm" else "hmm" +} diff --git a/android/app/src/main/java/com/noop/analytics/CoachSuggestions.kt b/android/app/src/main/java/com/noop/analytics/CoachSuggestions.kt new file mode 100644 index 0000000000..26525b477e --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/CoachSuggestions.kt @@ -0,0 +1,100 @@ +package com.noop.analytics + +import com.noop.data.DailyMetric + +// CoachSuggestions.kt — contextual coaching prompt chips derived from the user's own bands. +// +// Pure + deterministic so it is unit-testable without a strap, network, or device, and so the +// output is byte-identical to the Swift twin `StrandAnalytics.CoachSuggestions` (the cross-platform +// parity contract: the same inputs produce the same chip strings on both platforms). Reads only +// DailyMetric fields that already live on-device; no new egress, no telemetry. + +object CoachSuggestions { + + /** The stable generic set used when there is no usable data for today. Byte-identical to the + * Swift `fallback` list. */ + val fallback: List = listOf( + "How's my recovery trending this week?", + "What should today's training look like?", + "Analyse my sleep", + "Why am I run down?", + ) + + /** A stable generic prompt always appended as the last chip so there is a consistent entry point + * even when the contextual chips already cover the user's situation. */ + private const val stableGeneric = "Analyse my sleep" + + // Charge band cutoffs (mirror the autoregulation bands in the coach system prompt). + private const val CHARGE_RED_CUTOFF = 34.0 + private const val CHARGE_GREEN_CUTOFF = 67.0 + /** HRV "trending down": today's HRV below 85% of the trailing-30-day baseline (excluding today). */ + private const val HRV_DOWN_RATIO = 0.85 + /** Sleep "poor night": under 6h (360 min). */ + private const val POOR_SLEEP_MIN = 360.0 + /** "Already loaded" strain: a day strain at/above 14 reads as a high-load day. */ + private const val HIGH_STRAIN = 14.0 + /** Max chips surfaced. */ + private const val MAX_CHIPS = 4 + + /** + * Build 2–4 contextual prompt chips from today's bands, falling back to [fallback] when there is + * no usable data. [recent] is oldest→newest and is used for the trailing-30-day HRV baseline. + * + * Byte-twin of `StrandAnalytics.CoachSuggestions.suggestions(for:recent:)`. If you change a chip + * string here, change it in the Swift twin in the same PR. + */ + fun suggestions(today: DailyMetric?, recent: List): List { + if (today == null) return fallback + val charge = today.recovery + val hrv = today.avgHrv + val sleep = today.totalSleepMin + val strain = today.strain + // No usable signal at all → generic fallback. + if (charge == null && hrv == null && sleep == null && strain == null) return fallback + + val chips = mutableListOf() + + // 1. Charge band → one readiness prescription chip. + if (charge != null) { + chips += when { + charge < CHARGE_RED_CUTOFF -> "Active recovery only today — what should I do?" + charge < CHARGE_GREEN_CUTOFF -> "Quality over volume today — plan my session" + else -> "Green light — how hard can I push today?" + } + } + + // 2. HRV trending down vs trailing-30-day baseline (excluding today). + if (hrv != null) { + val baseline = hrvBaseline(recent, excluding = today.day) + if (baseline != null && baseline > 0 && hrv < HRV_DOWN_RATIO * baseline) { + chips += "Why is my HRV trending down?" + } + } + + // 3. Poor sleep (< 6h). + if (sleep != null && sleep < POOR_SLEEP_MIN) { + chips += "I slept poorly — how do I recover today?" + } + + // 4. Already a high-strain day. + if (strain != null && strain >= HIGH_STRAIN) { + chips += "Have I done enough today, or push more?" + } + + // Signals present but none of the band conditions fired → generic fallback (avoids a lone + // stable chip when there's nothing contextual to say). + if (chips.isEmpty()) return fallback + // Cap the CONTEXTUAL chips at MAX_CHIPS−1 so the stable generic always survives as the last + // chip (a consistent entry point), then append it. Total never exceeds MAX_CHIPS. + return chips.take(MAX_CHIPS - 1) + stableGeneric + } + + /** Mean of [avgHrv] over the trailing 30 days of [recent], excluding [excludingDay]. + * Returns null when no qualifying night has an HRV value. Pure; byte-twin of the Swift helper. */ + private fun hrvBaseline(recent: List, excluding: String): Double? { + val values = recent.takeLast(30).filter { it.day != excluding } + .mapNotNull { it.avgHrv } + if (values.isEmpty()) return null + return values.sum() / values.size + } +} diff --git a/android/app/src/main/java/com/noop/analytics/ConnectionReadout.kt b/android/app/src/main/java/com/noop/analytics/ConnectionReadout.kt index c9b413dd2a..05f447d847 100644 --- a/android/app/src/main/java/com/noop/analytics/ConnectionReadout.kt +++ b/android/app/src/main/java/com/noop/analytics/ConnectionReadout.kt @@ -211,20 +211,134 @@ object ConnectionReadout { fun clockLatchedLabel(deviceClockUnix: Long?, strapNewestUnix: Long? = null): String { val ceiling = ConnectionTrace.RTC_EPOCH_CEILING_UNIX if (deviceClockUnix != null) return if (deviceClockUnix < ceiling) "no (RTC reads 1970/71)" else "yes" - if (strapNewestUnix != null) return if (strapNewestUnix < ceiling) "no (RTC reads 1970/71)" else "yes" + // #1823: this branch has NOT read a clock - it is reached when no correlation exists, which is + // every 5/MG. The only evidence is how the strap DATED its records, so say that rather than + // claiming a clock reading we never took. Twin of the Swift wording. + if (strapNewestUnix != null) return if (strapNewestUnix < ceiling) "no (records dated 1970/71)" else "yes" return "no (waiting for the strap clock)" } + /** #1818: at or above this charge the "charge it" remedy is already satisfied, so repeating it is + * noise. Twin of the Swift constant - the two must move together or the platforms give different + * advice for the same strap. */ + const val RTC_ALREADY_CHARGED_PCT: Double = 95.0 + /** #987: a plain-words warning when the strap RTC reads epoch-era (~1970/71), from EITHER signal we * hold (the correlated device clock or the strap's newest banked-record timestamp). null when both - * look sane or neither was seen yet - we never fabricate a fault. Twin of the Swift warning. */ - fun rtcWarning(deviceClockUnix: Long?, strapNewestUnix: Long?): String? { + * look sane or neither was seen yet - we never fabricate a fault. Twin of the Swift warning. + * + * #1818: the remedy is battery-dependent. A flat battery resets the RTC, so on a low strap + * "charge it" is real advice. On an ALREADY-charged strap it is not, and the old copy sent users + * at 100% round a loop they had already run. + * + * The charged branch deliberately states only what holds for EVERY strap - that charging again + * will not change it - and asks for a log. It must NOT claim NOOP re-sends the clock on every + * connect: that is true on WHOOP4 (runConnectHandshake calls SET_CLOCK unconditionally, both + * payload forms, #120) but FALSE on a 5/MG, where the clock write is gated behind didBond, and an + * unbondable 5/MG (#1635) is never clocked at all - precisely the strap most likely to be showing + * this warning. Explaining the mechanism in the sentence is how the original bug happened. + * [batteryPct] null (not yet read) keeps the charge advice - we only withdraw it on evidence. + * Callers MUST pass a reading from the CURRENT link and not a last-known charge that outlives it - + * a stale 100% would suppress the advice in the one case it is right, a strap that ran flat and + * reset its RTC. */ + fun rtcWarning(deviceClockUnix: Long?, strapNewestUnix: Long?, batteryPct: Double? = null): String? { val ceiling = ConnectionTrace.RTC_EPOCH_CEILING_UNIX val clockBad = deviceClockUnix != null && deviceClockUnix > 0L && deviceClockUnix < ceiling val newestBad = strapNewestUnix != null && strapNewestUnix > 0L && strapNewestUnix < ceiling if (!clockBad && !newestBad) return null - return "Strap clock reads 1970/71 (never set since its last reset), so it is not banking history. " + - "Charge the strap to 100% and reconnect so the clock latches." + val lead = "Strap clock reads 1970/71 (never set since its last reset), so it is not banking history. " + if (batteryPct != null && batteryPct >= RTC_ALREADY_CHARGED_PCT) { + return lead + + "The strap is already charged, so charging again will not change this. Export a strap " + + "log from Test Centre so the clock exchange can be read." + } + return lead + "Charge the strap to 100% and reconnect so the clock latches." + } + + /** #1809: one-line account of a finished BLE link, logged on every disconnect. Twin of the Swift + * formatter. + * + * A strap log could not previously answer "did the strap send anything?". Inbound notifications only + * stamped a liveness timestamp that was then discarded, so a reporter chasing a silent strap had to + * infer silence from the fact that every LOGGED line happened to be outgoing - which measures NOOP's + * logging, not the strap. This measures the strap. + * + * [realtimeArmed] matters because the #80 marginal-radio fallback only counts a drop when the R10/R11 + * burst was actually armed; armed=no says up front that the detector cannot trip for this link, + * however many times the loop repeats. + * + * Milliseconds are printed raw: no float formatting, so the two platforms cannot round apart. + * [upMillis] is Long, not Int: Swift's Int is 64-bit, so an Int here would be the NARROWER type and + * a link held past ~24.8 days would wrap negative and print "up 0ms" - a dead-looking link that was + * in fact the healthiest one we ever had. Rare, silent, and exactly backwards, so use the real twin. */ + fun linkEpitaph(upMillis: Long, inboundFrames: Int, inboundBytes: Int, cmdChannelFrames: Int, + realtimeArmed: Boolean, ended: String): String { + var line = "Link epitaph: up ${maxOf(0L, upMillis)}ms, inbound ${maxOf(0, inboundFrames)} frames / " + + "${maxOf(0, inboundBytes)} bytes (cmd-channel ${maxOf(0, cmdChannelFrames)}), " + + "realtime armed=${if (realtimeArmed) "yes" else "no"}, ended=$ended" + if (inboundFrames <= 0) { + line += " - the strap sent NOTHING on this link" + } + return line + } + + /** + * #1635: what a finished link actually stored, split by PATH. + * + * The epitaph counts frames. That is right for a silent strap and wrong for one talking over only part + * of its surface: an unbonded 5/MG streams heart rate and R-R all night while nothing bond-gated + * lands, and the epitaph reports hundreds of healthy inbound frames. + * + * The split has to be by PATH, not by stream, and an earlier live-only version of this line got that + * wrong. `extractStreams` — the realtime decoder — produces only hr, rr, events and battery. Gravity, + * respiratory, skin temperature, SpO2 and steps come exclusively from the historical decoder behind + * the offload. A live-only tally therefore printed "nothing banked live for: gravity, resp, …" on + * EVERY link, bonded or not: a constant wearing the costume of a finding. + * + * So both paths are counted and named. The offload is where the bond shows: an unbonded strap defers + * backfill entirely, so `offload none` is the real signal, and a healthy sync fills it. + * + * Battery is absent on purpose. It rides the standard 0x2A19 profile, so it banks with or without the + * bond and answers nothing here. [offloadSteps] is nullable for the same reason it is on Apple: a + * platform that cannot measure a stream omits it rather than reporting a zero that reads as a fault. + * + * Counts are rows ACCEPTED, so a re-offload of already-stored records reads zero — correct, since the + * question is what the database gained. Pure, total and clamped: it runs on the teardown path, where + * throwing would cost the report it exists to produce. Twin of the Swift formatter. + */ + fun linkBankedSummary( + liveHr: Int, liveRr: Int, offloadChunks: Int, + offloadHr: Int, offloadRr: Int, offloadGravity: Int, offloadResp: Int, + offloadSkinTemp: Int, offloadSpo2: Int, offloadSteps: Int?, + ): String { + val live = "live hr=${maxOf(0, liveHr)} rr=${maxOf(0, liveRr)}" + val offload = (listOf( + "hr" to offloadHr, "rr" to offloadRr, "gravity" to offloadGravity, "resp" to offloadResp, + "skinTemp" to offloadSkinTemp, "spo2" to offloadSpo2, + ) + listOfNotNull(offloadSteps?.let { "steps" to it })).map { (k, v) -> k to maxOf(0, v) } + val offloadTotal = offload.sumOf { it.second } + // Three distinguishable states, reported as FACTS rather than verdicts. "No chunks" is not + // evidence of a fault on its own: a short or command-only link never reaches backfill, and the + // reason it was skipped is already logged next to it ("Backfill: deferred — connect handshake not + // done yet (didBond=…)"). Editorialising here — an earlier draft said "offload did NOT run on this + // link" — reads as an accusation on a healthy 16-second connect. The epitaph above supplies the + // uptime a reader needs to weigh it. + // + // "Never ran" and "ran with nothing new" are still DIFFERENT and must not share a sentence. + // Rows are counted as ACCEPTED, so a reconnect that re-offloads already-stored records banks zero + // while the strap plainly handed its history over — `classifyCompletedOffload` already treats that + // as `bankedSensorRecords`, not a fault. Only the first case speaks to the bond. + if (maxOf(0, offloadChunks) == 0) { + return "banked this link: $live | offload none" + } + if (offloadTotal == 0) { + return "banked this link: $live | offload ran ${maxOf(0, offloadChunks)} chunk(s), no new rows" + } + val body = offload.joinToString(" ") { (k, v) -> "$k=$v" } + val empty = offload.filter { it.second == 0 }.map { it.first } + if (empty.isEmpty()) return "banked this link: $live | offload $body" + return "banked this link: $live | offload $body - nothing banked from the offload for: " + + empty.joinToString(", ") } /** #987: freshness label for the "last frame" readout row ("12s ago" / "no frames yet"). [nowUnix] diff --git a/android/app/src/main/java/com/noop/analytics/DayCycle.kt b/android/app/src/main/java/com/noop/analytics/DayCycle.kt new file mode 100644 index 0000000000..3f96e1a9d7 --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/DayCycle.kt @@ -0,0 +1,85 @@ +package com.noop.analytics + +/** + * User-selectable ownership rule for additive daily metrics. + * + * Sleep and recovery values keep their night/wake-day semantics; this setting is for values accumulated + * while awake, such as steps, energy and cardiovascular load. + */ +enum class DayCycleMode(val persistedValue: String) { + SLEEP_ONSET("sleep_onset"), + MIDNIGHT("midnight"); + + companion object { + fun fromPersisted(value: String?): DayCycleMode = entries.firstOrNull { + it.persistedValue == value + } ?: SLEEP_ONSET + } +} + +/** Shared cycle window consumed by every additive daily metric. */ +data class DayCycleWindow( + val id: String, + val startInclusive: Long, + val endExclusive: Long, + val displayDay: String, + val source: Source, +) { + enum class Source { DETECTED_SLEEP, EDITED_SLEEP, SYNTHETIC_MIDNIGHT, CALENDAR } +} + +object DayCycleResolver { + const val MIN_SYNTHETIC_MIDNIGHT_AGE_SECONDS = 18 * 3_600L + const val ABSOLUTE_MAX_OPEN_SECONDS = 40 * 3_600L + + /** Midnight is always available and is also the honest cold-start/failure fallback. */ + fun calendarWindow(now: Long, tzOffsetSeconds: Long): DayCycleWindow { + val local = now + tzOffsetSeconds + val dayNumber = Math.floorDiv(local, SleepStageTotals.SECONDS_PER_DAY) + val start = dayNumber * SleepStageTotals.SECONDS_PER_DAY - tzOffsetSeconds + val day = AnalyticsEngine.dayString(start, tzOffsetSeconds) + return DayCycleWindow("calendar:$day", start, now, day, DayCycleWindow.Source.CALENDAR) + } + + /** First local midnight that does not truncate a freshly-started sleep cycle. */ + fun fallbackMidnightAfter(start: Long, tzOffsetSeconds: Long): Long { + val minimum = start + MIN_SYNTHETIC_MIDNIGHT_AGE_SECONDS + val local = minimum + tzOffsetSeconds + val dayNumber = Math.floorDiv(local, SleepStageTotals.SECONDS_PER_DAY) + val atMidnight = dayNumber * SleepStageTotals.SECONDS_PER_DAY - tzOffsetSeconds + return if (atMidnight >= minimum) atMidnight + else (dayNumber + 1) * SleepStageTotals.SECONDS_PER_DAY - tzOffsetSeconds + } + + /** + * Resolve the active window. Sleep-onset mode stays open across midnight even when awake coverage is + * unavailable. The absolute cap still prevents a stale sleep boundary from remaining active forever. + * + * That "even when" is unconditional, not a gate: an earlier design decided it from whether awake + * coverage was reliable and carried a `reliableAwakeCoverage` parameter for it. The gate was dropped + * but the parameter survived — unread on both platforms, and passed `false` by every one of its call + * sites — so it was removed rather than left looking like a switch someone could flip. + */ + fun activeWindow( + mode: DayCycleMode, + latestSleep: DayCycleWindow?, + now: Long, + tzOffsetSeconds: Long, + ): DayCycleWindow { + if (mode == DayCycleMode.MIDNIGHT || latestSleep == null) { + return calendarWindow(now, tzOffsetSeconds) + } + val age = now - latestSleep.startInclusive + val mustFallback = age >= ABSOLUTE_MAX_OPEN_SECONDS + if (!mustFallback) return latestSleep.copy(endExclusive = now) + val boundary = fallbackMidnightAfter(latestSleep.startInclusive, tzOffsetSeconds) + val day = AnalyticsEngine.dayString(boundary, tzOffsetSeconds) + return DayCycleWindow( + id = "synthetic:$day", + startInclusive = boundary, + endExclusive = now, + displayDay = day, + source = DayCycleWindow.Source.SYNTHETIC_MIDNIGHT, + ) + } +} diff --git a/android/app/src/main/java/com/noop/analytics/DayCycleIntelligenceIntegration.kt b/android/app/src/main/java/com/noop/analytics/DayCycleIntelligenceIntegration.kt new file mode 100644 index 0000000000..fe70628ce4 --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/DayCycleIntelligenceIntegration.kt @@ -0,0 +1,147 @@ +package com.noop.analytics + +import com.noop.data.DailyMetric +import com.noop.data.MetricSeriesRow +import com.noop.data.SleepSession +import com.noop.data.WhoopRepository + +/** Narrow adapter between the generic day-cycle domain and the legacy scoring orchestrator. */ +internal object DayCycleIntelligenceIntegration { + const val ONSET_KEY = "day_cycle_onset_ts" + + internal data class PersistedBoundary( + val boundary: PhysiologicalSteps.CycleBoundary, + val wakeDay: String, + val owner: String, + val sleepContext: DetectedSleep, + ) + + fun integratedStepValue(calendarSteps: Int?, established: Boolean, cycleSteps: Int?): Int? = + if (established) cycleSteps else calendarSteps + + fun markerRewriteSourceIds( + currentComputedSources: List, + candidateComputedSources: List, + ): List = (currentComputedSources + candidateComputedSources).distinct() + + fun persistedBoundaries( + candidates: List>, + sessionsByOwner: Map>, + markerOnsetByOwnerAndDay: Map>, + currentBoundaryWakeDays: Set, + tzOffsetSeconds: Long, + habitualMidsleepSec: Long?, + ): List { + val claimedDays = currentBoundaryWakeDays.toMutableSet() + val recovered = ArrayList() + for ((owner, _) in candidates.sortedWith(compareBy> { it.second }.thenBy { it.first })) { + val sessionsByWakeDay = sessionsByOwner[owner].orEmpty() + .filter { it.endTs > it.effectiveStartTs } + .groupBy { AnalyticsEngine.dayString(it.endTs, tzOffsetSeconds) } + for ((wakeDay, sessions) in sessionsByWakeDay.toSortedMap()) { + if (wakeDay in claimedDays) continue + val markerOnset = markerOnsetByOwnerAndDay[owner]?.get(wakeDay) ?: continue + val blocks = sessions.map { session -> + PhysiologicalSteps.SleepBlock( + session.startTs, session.endTs, session.startTs.toString(), session.startTsAdjusted, + ) + } + val winner = PhysiologicalSteps.classifyForCycle( + blocks, tzOffsetSeconds, habitualMidsleepSec, + ).firstOrNull { + it.kind == PhysiologicalSteps.SleepKind.MAIN_SLEEP && it.effectiveOnset == markerOnset + } ?: continue + val persisted = sessions.firstOrNull { + it.startTs.toString() == winner.id && it.effectiveStartTs == markerOnset + } ?: continue + recovered += PersistedBoundary( + PhysiologicalSteps.CycleBoundary("persisted:$owner:${persisted.startTs}", markerOnset), + wakeDay, + owner, + DetectedSleep( + persisted.effectiveStartTs, persisted.endTs, persisted.efficiency ?: 0.0, + AnalyticsEngine.decodeStages(persisted.stagesJSON), persisted.restingHr, persisted.avgHrv, + ), + ) + claimedDays += wakeDay + } + } + return recovered + } + + fun recoveredMarkerRows( + recovered: List, + computedDeviceId: (String) -> String, + ): List = recovered.map { + MetricSeriesRow(computedDeviceId(it.owner), it.wakeDay, ONSET_KEY, it.boundary.onset.toDouble()) + } + + fun cacheKey( + owner: String, + sleepId: String, + onset: Long, + end: Long, + stepRevision: String, + sleepContextSignature: String, + contributingDayKeys: List>, + ): String = buildString { + append(owner).append('|').append(sleepId).append('|').append(onset).append('|').append(end) + .append("|stepRevision=").append(stepRevision) + .append("|sleepContext=").append(sleepContextSignature) + for ((day, witness) in contributingDayKeys.sortedBy { it.first }) append('|').append(day).append('=').append(witness) + } + + fun shouldRecount(cachedKey: String?, currentKey: String): Boolean = cachedKey != currentKey + + private fun dayWitness(owner: String, result: DayResult): String = buildString { + append(owner).append(':').append(result.daily.steps ?: "nil") + for (sleep in result.sleepSessions.sortedBy { it.start }) { + append('|').append(sleep.start).append('-').append(sleep.end).append(':').append(sleep.stages.hashCode()) + } + } + + suspend fun compute( + scoredNights: List, + editedRows: List, + resolvedOwners: Map, + candidatePriorities: List>, + repo: WhoopRepository, + tzOffsetSeconds: Long, + habitualMidsleepSec: Long?, + windowStart: Long, + nowSeconds: Long, + stepTicksPerStep: Double, + traceSink: ((String) -> Unit)?, + mode: DayCycleMode, + profile: UserProfile, + maxHROverride: Double?, + effortMethod: StrainScorer.Method, + ): PhysiologicalStepCycleEngine.Result { + val witnesses = scoredNights.associate { result -> + result.daily.day to dayWitness(resolvedOwners[result.daily.day].orEmpty(), result) + } + return PhysiologicalStepCycleEngine.compute( + scoredNights, editedRows, resolvedOwners, candidatePriorities, witnesses, repo, + tzOffsetSeconds, habitualMidsleepSec, windowStart, nowSeconds, stepTicksPerStep, traceSink, mode, + profile, maxHROverride, effortMethod, + ) + } + + fun apply( + daily: DailyMetric, + result: PhysiologicalStepCycleEngine.Result, + computedId: String, + metricRows: MutableList, + ): DailyMetric { + val established = result.firstCycleWakeDay?.let { daily.day >= it } == true + result.boundaryOnsetByWakeDay[daily.day]?.let { onset -> + metricRows += MetricSeriesRow(computedId, daily.day, ONSET_KEY, onset.toDouble()) + } + return daily.copy( + steps = integratedStepValue(daily.steps, established, result.cycleStepsByWakeDay[daily.day]), + strain = if (established) result.cycleStrainByWakeDay[daily.day] else daily.strain, + activeKcalEst = if (established) result.cycleCaloriesByWakeDay[daily.day] else daily.activeKcalEst, + exerciseCount = if (established) result.cycleWorkoutCountByWakeDay[daily.day] else daily.exerciseCount, + ) + } +} diff --git a/android/app/src/main/java/com/noop/analytics/EffectRanker.kt b/android/app/src/main/java/com/noop/analytics/EffectRanker.kt index 41431a94d7..6085da1d9b 100644 --- a/android/app/src/main/java/com/noop/analytics/EffectRanker.kt +++ b/android/app/src/main/java/com/noop/analytics/EffectRanker.kt @@ -113,27 +113,65 @@ object EffectRanker { * Rank every behaviour against one outcome across the lag set, keeping each behaviour's * best lag. Mirrors Swift's ordering on the surviving rows. * - * @param behaviors per behaviour name, the SET of "yyyy-MM-dd" days it was logged (dose ≥ 1). + * @param behaviors per behaviour name, the SET of "yyyy-MM-dd" days it was logged YES (dose ≥ 1). + * @param controls per behaviour name, the days it was logged NO. Required, and separate from + * [behaviors] on purpose: a behaviour missing from this map has no controls and yields nothing, + * which fails CLOSED. A caller that forgets it loses the insight rather than getting a wrong one + * measured against every day the user never opened the journal. * @param outcomeByDay the daily outcome series keyed "yyyy-MM-dd". * @param outcome the outcome label carried onto each RankedEffect. */ fun rank( behaviors: Map>, + controls: Map>, outcomeByDay: Map, outcome: String, ): List { val rows = ArrayList() for (name in behaviors.keys.sorted()) { val days = behaviors.getValue(name) - val r = bestLag(days, outcomeByDay, name, outcome) + val r = bestLag(days, controls[name].orEmpty(), outcomeByDay, name, outcome) if (r != null) rows.add(r) } return sorted(rows) } + /** + * Rank every behaviour against one outcome at lag 0 only — the Kotlin twin of Swift's + * `BehaviorInsights.rank`, ordering included. + * + * Exists because the Insights screen ranks at lag 0 while the Insights Hub searches the lag set, and + * Android had been doing the lag-0 version with its OWN copy of the math: a "crude significance" + * (|d| ≥ 0.5 with ≥ 3 a side, self-described as a stand-in for a t-test) against iOS's Welch p on the + * same screen. Two platforms flagged different behaviours as significant from identical journals. + * Routing it here retires that copy; the parity contract wants one implementation, not two that agree + * on a good day. + * + * Sort mirrors Swift exactly: significant first, then larger |Cohen's d|, then behaviour name — the + * last one so a dictionary-ordered walk still produces a stable list. + */ + fun rankNoLag( + behaviors: Map>, + controls: Map>, + outcomeByDay: Map, + outcome: String, + ): List { + val effects = ArrayList() + for ((name, days) in behaviors) { + val e = effect(days, controls[name].orEmpty(), outcomeByDay, name, outcome) + if (e != null) effects.add(e) + } + return effects.sortedWith( + compareByDescending { it.significant } + .thenByDescending { abs(it.cohensD) } + .thenBy { it.behavior }, + ) + } + /** Find the best-lag RankedEffect for ONE behaviour against ONE outcome, or null. */ fun bestLag( behaviorDays: Set, + controlDays: Set, outcomeByDay: Map, behavior: String, outcome: String, @@ -142,7 +180,7 @@ object EffectRanker { var bestEffect: BehaviorEffect? = null for (lag in lagSet) { val shifted = shiftedOutcome(outcomeByDay, lag) - val e = effect(behaviorDays, shifted, behavior, outcome) ?: continue + val e = effect(behaviorDays, controlDays, shifted, behavior, outcome) ?: continue if (minOf(e.nWith, e.nWithout) < minGroupForSignificance) continue val cur = bestEffect @@ -182,12 +220,28 @@ object EffectRanker { // Effect (byte-identical port of Swift BehaviorInsights.effect + helpers). /** - * Compute the effect of [behavior] on [outcome]. Days are partitioned into "with" - * (day ∈ behaviorDays) and "without", restricted to days with an outcome value. Returns - * null unless both groups are non-empty AND there are ≥ 3 points total. + * Compute the effect of [behavior] on [outcome]. "With" is day ∈ [behaviorDays]; "without" is + * day ∈ [controlDays]. Days in NEITHER are dropped, and that is the point of the second set. + * + * The split used to be `if (behaviorDays.contains(day)) with else without`, which made every day + * carrying an outcome a control — so a behaviour logged Yes on 20 days and never logged at all on + * the other 100 was measured against those 100 as though the user had answered No. Reported by a + * user on Reddit, in those terms: "if I didn't track something for 100 days, NOOP takes that as a + * NO for 100 days, whereas it simply was not logged at all." + * + * The journal already distinguishes the three states — a No writes a row with answeredYes = false, + * and only Clear deletes the row — so nothing but this split was conflating them. + * + * Expect FEWER findings, not better-looking ones. Unlogged days inflated nWithout, which shrinks the + * Welch p and helps clear the min(nWith, nWithout) gate; dropping them removes confidence that was + * never earned. A behaviour with no No days now yields nothing, which is the honest answer: with no + * controls there is no comparison to make. + * + * Returns null unless both groups are non-empty AND there are ≥ 3 points total. */ internal fun effect( behaviorDays: Set, + controlDays: Set, outcomeByDay: Map, behavior: String, outcome: String, @@ -195,7 +249,13 @@ object EffectRanker { val withVals = ArrayList() val withoutVals = ArrayList() for ((day, value) in outcomeByDay) { - if (behaviorDays.contains(day)) withVals.add(value) else withoutVals.add(value) + when { + behaviorDays.contains(day) -> withVals.add(value) + controlDays.contains(day) -> withoutVals.add(value) + // Neither: the behaviour was not logged that day. Not a control — no information. + // A day in BOTH would count as "with"; callers merge imported ∪ native before building + // these, so one question cannot hold two answers for one day. + } } val n1 = withVals.size diff --git a/android/app/src/main/java/com/noop/analytics/HeartRateRecovery.kt b/android/app/src/main/java/com/noop/analytics/HeartRateRecovery.kt index 0e4aa154a2..7623295729 100644 --- a/android/app/src/main/java/com/noop/analytics/HeartRateRecovery.kt +++ b/android/app/src/main/java/com/noop/analytics/HeartRateRecovery.kt @@ -66,14 +66,23 @@ object HeartRateRecovery { private fun sustainedSeconds(threshold: Double, samples: List): Long { if (samples.size < 2) return 0L - var seconds = 0L + var currentSeconds = 0L + var longestSeconds = 0L for (i in 0 until samples.lastIndex) { val gap = samples[i + 1].ts - samples[i].ts - if (gap in 1..maximumContinuousGapSeconds && samples[i].bpm.toDouble() >= threshold) { - seconds += gap + if (gap <= 0L) continue + if (gap > maximumContinuousGapSeconds) { + currentSeconds = 0L + continue } + if (samples[i].bpm.toDouble() < threshold) { + currentSeconds = 0L + continue + } + currentSeconds += gap + longestSeconds = maxOf(longestSeconds, currentSeconds) } - return seconds + return longestSeconds } private fun median(values: List): Int? { diff --git a/android/app/src/main/java/com/noop/analytics/HrvAnalyzer.kt b/android/app/src/main/java/com/noop/analytics/HrvAnalyzer.kt index 686831112a..d29dcf3e33 100644 --- a/android/app/src/main/java/com/noop/analytics/HrvAnalyzer.kt +++ b/android/app/src/main/java/com/noop/analytics/HrvAnalyzer.kt @@ -405,10 +405,11 @@ object HrvAnalyzer { * arriving in real time, carrying no timestamps to measure coverage with — and suppressing those * would refuse honest readings, the opposite of the point. * - * Successive-difference statistics (RMSSD, pNN50) are deliberately NOT gated here. Their dominant - * error on a banked stream was the lost within-second emission order (#823, root-caused in #1072), - * which is fixed at the write path; whether they need a gate of their own is a question for a - * post-fix capture to answer, not an assumption to bake in now. + * Successive-difference statistics (RMSSD, pNN50) are not gated HERE, but they are no longer ungated + * by COVERAGE: see [successiveDiffIsTrustworthy] below. The question this comment left open was one for + * a post-fix capture to answer; that capture arrived and answered it yes. Note it answered THIS + * question only. The sibling deferral on [beatValuesAreTrustworthy], about beat-to-beat accuracy, is + * still open and still waiting on a capture of its own. */ fun beatSpreadIsTrustworthy(verdict: RrCoverageVerdict): Boolean = when (verdict) { RrCoverageVerdict.SAME_SECOND_OVER_COUNT, RrCoverageVerdict.CROSS_SECOND_OVER_COUNT -> false @@ -416,6 +417,50 @@ object HrvAnalyzer { RrCoverageVerdict.UNMEASURABLE -> true } + /** + * Whether RMSSD / pNN50 can be trusted from a window with this coverage verdict (#1118). + * + * The sibling of [beatSpreadIsTrustworthy], and the same two verdicts refuse, but for its own reason + * rather than by borrowing that one's. SDNN is a spread over every interval, so a duplicated beat + * widens it. Successive-difference statistics fail the other way: a beat the heart never produced + * sits next to its neighbour with a near-zero difference, and RMSSD is the root mean square of + * exactly those differences, so an over-counted night reads LOW. Measured on a WHOOP 4.0 night whose + * banked R-R covers 2.13x the wall clock it spans, RMSSD reads 36 ms where the same night's beats + * collapsed read 41 ms; the direction of the error is not obvious from the number, which is precisely + * why it cannot be shown. + * + * The comment on [beatSpreadIsTrustworthy] used to say these were deliberately ungated because their + * dominant error was the lost within-second emission order fixed at the write path (#823 / #1072), + * and that whether they needed a gate of their own was for a post-fix capture to answer. The capture + * arrived: a build carrying that fix, on a WHOOP 4.0, still reports `crossSecondOverCount` on every + * measured night, with 91% of the inflating beat-time on seconds written by more than one delivery. + * The write-path fix did not make the stream clean, so the assumption it licensed no longer holds. + * + * WITHHOLD, DO NOT FABRICATE. This trades a wrong number for a blank on every affected night, which + * is a real cost: HRV is the dominant Charge driver, so a gated night drops that term and renormalises + * the rest. That cost is accepted deliberately. A contaminated RMSSD does not merely display wrong, it + * enters the personal baseline the following nights are scored against, so showing it spreads the + * error into days whose own capture was clean. + * + * THE COST REACHES FURTHER THAN THE CARD, and a reader should know it before widening this rule. + * [Baselines.update] holds on a null night without advancing `nValid`, so on an install whose EVERY + * night gates, the HRV baseline never reaches [Baselines.minNightsSeed] and stays CALIBRATING + * indefinitely. That is the WHOOP 4.0-only case exactly. It is still the right answer, because the + * alternative is seeding a personal baseline from beats the heart did not produce and scoring every + * later night against it, but it means this gate can leave a strap with no HRV baseline at all rather + * than merely a gap in one. A mixed install is unaffected: its clean nights seed the baseline and the + * gated ones skip-and-hold. + * + * UNDER_COVERED and UNMEASURABLE stay trusted for the same reason they do above: neither duplicates a + * beat, and UNMEASURABLE is what an honest live spot reading looks like. Pure. Byte-parity twin of + * Swift `successiveDiffIsTrustworthy`. + */ + fun successiveDiffIsTrustworthy(verdict: RrCoverageVerdict): Boolean = when (verdict) { + RrCoverageVerdict.SAME_SECOND_OVER_COUNT, RrCoverageVerdict.CROSS_SECOND_OVER_COUNT -> false + RrCoverageVerdict.PLAUSIBLE, RrCoverageVerdict.UNDER_COVERED, + RrCoverageVerdict.UNMEASURABLE -> true + } + /** * Whether a live spot capture collected more beat time than the wall clock allows (no per-beat * timestamps for [rrCoverage], but the capture knows how long it ran). Only over-count rejects; @@ -487,8 +532,12 @@ object HrvAnalyzer { * median, so no per-beat artifact rule can see the fault — it is in the decomposition, not in * outliers. * - * Successive-difference statistics (RMSSD, pNN50) are deliberately NOT gated here, for the same - * reason they are not gated by the coverage verdict: that is a question for a capture to answer. + * Successive-difference statistics (RMSSD, pNN50) are deliberately NOT gated here. They ARE now gated + * on the coverage verdict ([successiveDiffIsTrustworthy]), but that is a different fault and a + * different population, and the capture that justified it says nothing about this one. Gating here + * would sweep in the very night described above: coverage 1.03, verdict PLAUSIBLE, and untrustworthy + * only in its decomposition. Refusing those needs its own capture, and inventing one from a WHOOP 4.0 + * over-count would be exactly the assumption the paragraph above refused to bake in. */ fun beatValuesAreTrustworthy(beatAccurateFraction: Double): Boolean = // Negated `<` so a NaN input lands on `true` — NaN means "not measured", and an unmeasured @@ -772,9 +821,23 @@ object HrvAnalyzer { */ internal fun msToInt(ms: Double): Int = if (ms > 0) (ms + 0.5).toInt() else 0 - /** Whole-percent, integer half-up, so both platforms round a tie the same way. 0 when [total] is 0. */ + /** Whole-percent, integer half-up, so both platforms round a tie the same way. 0 when [total] is 0. + * + * The arithmetic is widened to `Long` because [deliveryHistogram] passes BEAT-TIME IN MILLISECONDS + * summed over a whole night, not a row count. The numerator is `part * 200 + total`, so with + * `part <= total` it needs more than 32 bits from `total >= 10,683,999` ms - 2.97 h of beat-time on + * multi-delivery seconds, and a real over-count night carries 3-4x that. The multiply ALONE would + * survive to `part >= 10,737,419`; the `+ total` term is what brings the bound down, so quoting the + * multiply's limit understates the exposure. Kotlin's `Int` wraps silently while the Swift twin + * (64-bit `Int`) read correctly from the same source - the parity these two are supposed to hold. + * + * The wrap did NOT always land negative, which is the dangerous half: a 100% multi-share 8 h night + * (`pct(28_800_000, 28_800_000)`) returned 25 - positive, plausible, and wrong by 75 points. A + * negative `multiMs` was a symptom of this, never the test for it, so every pre-fix Android capture + * past the bound is suspect including the ones that read fine. Widening the operation rather than + * the signature keeps every caller and the half-up tie behaviour untouched. */ internal fun pct(part: Int, total: Int): Int = - if (total > 0) (part * 200 + total) / (total * 2) else 0 + if (total > 0) ((part.toLong() * 200 + total) / (total.toLong() * 2)).toInt() else 0 /** * #1008: a compact, deterministic RAW-ROW sample of the beats around the DENSEST second, for the diff --git a/android/app/src/main/java/com/noop/analytics/HypnogramCoverage.kt b/android/app/src/main/java/com/noop/analytics/HypnogramCoverage.kt new file mode 100644 index 0000000000..695d3c606e --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/HypnogramCoverage.kt @@ -0,0 +1,189 @@ +package com.noop.analytics + +import org.json.JSONArray + +/** + * How much of a sleep session's `[startTs, endTs)` span its stage segments actually account for. + * + * WHY THIS EXISTS. A session's stage timeline is supposed to TILE its span — `SleepStageTotals` says so + * explicitly ("the segment stages noop stores ... TILE the window ... Σ stage minutes equals the clock + * span"), and every consumer is written as though it does. One producer breaks that invariant: a + * device-PROVIDED hypnogram assembled from records that arrived INCOMPLETE. The Oura path + * ([com.noop.oura.OuraSleepSessionMapping]) merges only CONTIGUOUS codes, so a sleep-phase page that + * never arrived leaves a hole in `stagesJSON` while `startTs`/`endTs` still span the whole night. The + * result is a session that looks well-formed — non-empty, many segments, a plausible efficiency — but + * describes a fraction of the night it claims. + * + * Measured on 31 consecutive ring nights, 8 of them came in under 95% and one covered 23% of its own + * span (601 min claimed, 140 min of segments). Downstream that night was stored as 70 minutes of sleep + * against a paired strap's 494, and nothing flagged it: the merge's richness rule tests only that stages + * are PRESENT, and Rest's two confidence guards are `gravitySparse` (inert here — Oura stores no gravity + * at all, so `isGravitySparse` returns false for every ring night) and the #H9 restorative floor (needs + * efficiency >= 0.85; the holed night read 0.50). + * + * So the missing quantity is not a new measurement — it is a RATIO of two numbers already stored. + * Nothing here is persisted and no migration is needed: coverage is derived on read, which also means it + * applies retroactively to nights already in the database. + * + * HONEST-DATA: this only ever reports how much of the night was OBSERVED. It never fills a hole in, and + * in particular it must not let a caller treat unobserved time as awake — we do not know what happened + * there, and asserting wake would be the same overreach in the opposite direction. + * + * PARITY: pure + deterministic, byte-identical to the Swift twin (`WhoopStore.HypnogramCoverage`). Keep + * the two in lockstep; `HypnogramCoverageTest` pins the ratio against a Swift-generated oracle. + */ +object HypnogramCoverage { + + /** + * Coverage at or above which a stage timeline is treated as describing its whole span. + * + * The observed split is wide: healthy nights land at 99–100%, the broken ones at 23–93%. 0.95 sits + * in the empty middle, so the gate is not balanced on the edge of the data. Mirrors Swift + * `minCoverage`. + */ + const val minCoverage: Double = 0.95 + + /** + * The covered fraction of [spanSeconds], or null when the question does not apply. + * + * Returns null — meaning "unknown, do not judge" — rather than 0 when there is nothing to measure, + * so an unknown coverage can never be mistaken for a bad one by a caller comparing against + * [minCoverage]. Clamped to at most 1: segments that overlap or overhang would otherwise report more + * than a full night, and for a completeness gate the safe direction is to read that as "complete" + * rather than to invent a failure out of malformed input. + */ + fun fraction(coveredSeconds: Double, spanSeconds: Double): Double? { + if (spanSeconds <= 0.0 || coveredSeconds <= 0.0) return null + return minOf(1.0, coveredSeconds / spanSeconds) + } + + /** + * The covered fraction of [spanSeconds] for a session's stored `stagesJSON`, or null when coverage is + * not a meaningful question for that payload. + * + * null for: a null/blank/`"[]"` payload (no stages at all — that is the richness question, not this + * one), for the IMPORTED minute-dict shape `{light,deep,rem,awake}` and for Health Connect's + * `{stage,min}` array, neither of which carries timestamps to compare against a span, and for an + * array holding any non-object element (see the loop below for why that bails rather than measuring + * the remainder). + * + * SCOPE, precisely. Timestamp-free shapes are what keep this gate off the WHOOP CSV, Apple and + * Health Connect imports — they are never judged incomplete, so their behaviour is unchanged. That + * is NOT a blanket exemption for imports, and it was originally written as one: the Xiaomi Band + * importer emits real `{start,end,stage}` segments, and takes its span from `bedtime`/`wake_up_time` + * fields that are independent of the `items` array it builds those segments from. A Xiaomi night + * whose items do not reach its own bed/wake bounds IS judged holed here. That is arguably the right + * answer — the night genuinely is only partly described — but it is a real behaviour change for + * that importer, unvalidated against a Xiaomi export, and it is pinned by test rather than left to + * be discovered. + */ + fun fraction(stagesJson: String?, spanSeconds: Double): Double? = + fraction(coveredSeconds(stagesJson), spanSeconds) + + /** + * A JSON scalar read the way Swift's `(seg["start"] as? NSNumber)?.doubleValue` reads it. + * + * A number converts. A STRING does not — `NSString` is not an `NSNumber`, so the Swift side skips + * that segment, whereas `optDouble` would have parsed it and counted the segment here. A BOOL does + * convert, because `JSONSerialization` bridges `true`/`false` to `NSNumber` and yields 1/0; that is + * absurd in a stage payload and no producer emits it, but the two readers must not disagree + * anywhere either could be asked. Verified against the Swift twin compiled standalone rather than + * read off the page: `{"start":0}` reports `is Bool == true` under Foundation, so a bool EXCLUSION + * would have rejected every timeline whose first segment starts at zero. + */ + private fun num(v: Any?): Double? = when (v) { + is Number -> v.toDouble() + is Boolean -> if (v) 1.0 else 0.0 + else -> null + } + + /** + * True when the timeline is known to cover less than [minCoverage] of its span — i.e. the session + * demonstrably describes only part of the night it claims. Unknown coverage (null) is NOT holed: + * every guard built on this fails OPEN, so a payload shape this cannot measure keeps its existing + * behaviour instead of being silently downgraded. + */ + fun isHoled(stagesJson: String?, spanSeconds: Double): Boolean { + val f = fraction(stagesJson, spanSeconds) ?: return false + return f < minCoverage + } + + /** + * Seconds of [stagesJson] accounted for by timestamped segments, or 0 when the payload carries no + * measurable ones. + * + * Zero, not null, because this is a SUMMABLE quantity: a caller accumulating several fragments needs + * "this one contributed nothing" to add cleanly, and the "is coverage even a meaningful question" + * decision belongs to [fraction], which turns a zero total back into null. Callers wanting the + * unknown/known distinction for a SINGLE session must therefore keep using [fraction], not this. + * + * Returns 0 for every shape [fraction] documents as unmeasurable, by the same paths and in the same + * order — including the whole-payload bail on a non-object element, which must stay a bail here too + * (returning a PARTIAL sum for such a payload would resurrect exactly the Swift/Kotlin disagreement + * the `optJSONObject` bail above was added to close). Mirrors Swift `coveredSeconds(stagesJSON:)`. + */ + fun coveredSeconds(stagesJson: String?): Double { + val json = stagesJson?.trim() ?: return 0.0 + if (json.isEmpty() || json == "[]") return 0.0 + // The minute-dict shape (imported totals) throws here and falls through to zero — deliberately, + // since it carries no timestamps to measure. Same try/catch idiom as SleepStageTotals.minutes. + // Health Connect's `{stage,min}` array does NOT throw: it parses, reaches the loop, and every + // segment is skipped for want of bounds, so it exits with zero cover — unmeasurable by the same + // rule, along a different path. Both are pinned in the oracle. + val arr = try { JSONArray(json) } catch (_: Throwable) { return 0.0 } + var covered = 0.0 + for (i in 0 until arr.length()) { + // The Swift twin decodes the array as a WHOLE (`as? [[String: Any]]`), so ONE non-object + // element makes the entire payload unmeasurable there. Bail identically — DISCARDING the + // partial sum, which is what the Swift cast does — rather than measuring the remainder. + // Measured on the shipped pair, `[{seg},5]` and `[{seg},null]` read nil/not-holed on Swift + // and 0.1/HOLED here — and since this gate can only downgrade a night, refusing to judge a + // payload we do not fully understand is the safe half of that disagreement, and the one the + // module's fail-OPEN rule already commits to. + val seg = arr.optJSONObject(i) ?: return 0.0 + val s = num(seg.opt("start")) ?: continue + val e = num(seg.opt("end")) ?: continue + if (e <= s) continue + covered += e - s + } + return covered + } + + /** One fragment of a bridged main-night group: its stored payload and its own `[start, end)` span. */ + data class Fragment(val stagesJson: String?, val spanSeconds: Double) + + /** + * Coverage summed across a bridged main-night GROUP — the quantity a night is actually judged on. + * + * A night is not one row. `AnalyticsEngine.analyzeDay` bridges the day's main-sleep fragments into a + * group (#561) and accumulates coverage over ALL of them before testing the gate, so a per-row answer + * is the wrong question for anything presenting a night: two fragments that are individually 90% and + * 99% covered are one night at neither figure. This exists so a UI can ask the same question the + * engine asked without restating the accumulation, which is where a twin rule would drift. + * + * PARITY WITH THE ENGINE, exactly. Every fragment contributes its full span to the denominator whether + * or not its payload is measurable, and contributes cover only from timestamped segments — which + * reproduces the engine's decoded-segment accumulation term for term, including the mixed-source case + * (a timestamp-free fragment adds span and no cover, so a group mixing one with a timestamped fragment + * reads as holed). A group whose fragments are ALL timestamp-free totals 0 covered seconds and comes + * back null — unknown, not holed. Mirrors Swift `groupFraction(_:)`. + */ + fun groupFraction(fragments: List): Double? { + var covered = 0.0 + var span = 0.0 + for (f in fragments) { + span += f.spanSeconds + covered += coveredSeconds(f.stagesJson) + } + return fraction(covered, span) + } + + /** + * [isHoled] for a bridged main-night GROUP. Unknown coverage (null) is NOT holed — same fail-open + * contract as every other guard here. + */ + fun isHoledGroup(fragments: List): Boolean { + val f = groupFraction(fragments) ?: return false + return f < minCoverage + } +} diff --git a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt index 8c6e3e4682..dd4d4e492b 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt @@ -61,6 +61,17 @@ object IntelligenceEngine { */ private val analyzeGate = Mutex() + /** #1816: optional sink for whether the strap banked ANY motion in the calibration scan window. + * Set by the caller (AppViewModel / WhoopBleClient) before calling [analyzeRecent] and cleared + * after, so the Today tile can distinguish "Need N more phone-step days" (motion exists, phone + * half missing) from "No motion synced yet" (the motion half is the blocker). A field rather + * than a parameter because [analyzeRecentOnCpu] sits close to the JVM 64 KB method ceiling and + * the JaCoCo budget test (#1524) guards its margin — one more function-typed parameter + call + * site would push it over. Safe for the same reason [skippedSleepDays] and [dayScanCache] are: + * every pass runs under [analyzeGate], so there is no concurrent access. */ + @Volatile + var stepsHasMotionSink: ((Boolean) -> Unit)? = null + /** #1121: days this pass skipped for too little raw HR, emitted as ONE line after the loop instead of * one line each — see [skippedSleepDaysLine]. A FIELD, not a local, for a mechanical reason: * [analyzeRecentOnCpu] is `suspend` and sits 64 bytes under the JVM ceiling #1524 guards, so one more @@ -159,8 +170,15 @@ object IntelligenceEngine { /** Minimum HR samples in a day's window before it is worth scoring. */ const val MIN_HR_SAMPLES: Int = 200 - /** Read cap per stream read , matches the Swift 200_000 bound. */ + /** + * Read cap for the SPARSE streams (aux, resp, gravity, skin temp), matching the Swift bound. + * + * NOT the two heavy streams any more: HR and R-R take their caps from [StreamReadCap], because one + * number sized for HR silently truncated R-R (#1538). Anything reading a stream dense enough to + * approach this bound belongs there too. + */ const val STREAM_LIMIT: Int = 200_000 + internal const val PHYSIOLOGICAL_STEP_PAGE_SIZE: Int = 10_000 private const val SECONDS_PER_DAY: Long = 86_400L @@ -410,6 +428,7 @@ object IntelligenceEngine { // #1545: the Effort TRIMP recipe. The Context-aware caller reads NoopPrefs.effortMethod(context) // and passes it down, keeping this layer Context-free. EDWARDS default = byte-identical. effortMethod: StrainScorer.Method = StrainScorer.Method.EDWARDS, + dayCycleMode: DayCycleMode = DayCycleMode.SLEEP_ONSET, ): List = withContext(Dispatchers.Default) { // #1005: time the whole pass so a re-score STORM is visible in the strap log (the trigger lines // record WHY each pass runs; this records how many nights and how long — the CPU cost per run). @@ -422,7 +441,7 @@ object IntelligenceEngine { nowSeconds, ownerSource, manualStepCoefficient, persistStepsCalibration, baselineEpoch, recoveryEpoch, diag, useExperimentalSleepV2, useMotionAwareWake, sleepTraceSink, recoveryTraceSink, stepsTraceSink, universalSink, workoutsTraceSink, hrvTraceSink, deepHrvWindow, - spo2CandidateDisplay, effortMethod) + spo2CandidateDisplay, effortMethod, dayCycleMode) if (healed == 0) out // #899 heal re-pass: the pass above deleted overlapping duplicate sleep sessions AFTER its days // were scored, and the read-side dedup those days consumed had no bank-recency witness (the fresh @@ -433,7 +452,7 @@ object IntelligenceEngine { nowSeconds, ownerSource, manualStepCoefficient, persistStepsCalibration, baselineEpoch, recoveryEpoch, diag, useExperimentalSleepV2, useMotionAwareWake, sleepTraceSink, recoveryTraceSink, stepsTraceSink, universalSink, workoutsTraceSink, hrvTraceSink, deepHrvWindow, - spo2CandidateDisplay, effortMethod).first + spo2CandidateDisplay, effortMethod, dayCycleMode).first } diag("re-score: done — scored ${scored.size} night(s) in ${(System.nanoTime() - reScoreStart) / 1_000_000} ms (#1005)") scored @@ -537,6 +556,7 @@ object IntelligenceEngine { spo2CandidateDisplay: Boolean = false, // #1545: the Effort TRIMP recipe, threaded from the public wrapper. effortMethod: StrainScorer.Method = StrainScorer.Method.EDWARDS, + dayCycleMode: DayCycleMode = DayCycleMode.SLEEP_ONSET, // #899 heal re-pass: the second component of the return is how many overlapping duplicate sleep // sessions the heal below deleted this pass. The public wrapper re-runs ONCE when it is non-zero // so the affected days re-score against the cleaned store. @@ -597,6 +617,8 @@ object IntelligenceEngine { // on. Keyed by the local day. val readOwnerByDay = LinkedHashMap() val resolvedScoreOwnerByDay = LinkedHashMap() + // Boundary/cache witness assembled from the already-computed day result. The independent O(1) + // repository revision below handles step-only inserts even when the HR-keyed day cache is reused. // HRV baseline honours the manual "Recalibrate baseline" epoch (noop.hrvBaselineEpoch): pass the // per-value "yyyy-MM-dd" day keys (parallel to the values) so foldHistory drops every night before // the epoch. baselineEpoch is threaded down from the Context-aware caller (0.0 = no recalibration). @@ -648,7 +670,7 @@ object IntelligenceEngine { // the Sleep tab resolve to the identical block. Mirrors Swift. (#547) val (habitualMidsleepSec, nightlyHours) = computeHabitualSleep( repo, importedDeviceId, computedId, - nowLocalMidnight - maxDays * SECONDS_PER_DAY - 30 * 3_600L, nowSeconds, tzOffsetSeconds, + nowLocalMidnight - maxDays * SECONDS_PER_DAY - StreamReadCap.LOOKBACK_SECONDS, nowSeconds, tzOffsetSeconds, ) // Wave 0 (SL1/T1): personal sleep REGULARITY + population-anchored NEED, computed ONCE from the // trailing per-night durations and threaded to every analyzeDay below (mirrors the midsleep @@ -678,7 +700,7 @@ object IntelligenceEngine { val skinWornToleranceByOwner = HashMap() // #938: the WHOOP 4.0 ADC offset is per-device, not per-night. Learn one anchor per owner from the // whole scan window and reuse it for every night so cross-night deviations survive. - val skinAnchorScanFrom = nowLocalMidnight - (maxDays - 1).toLong() * SECONDS_PER_DAY - 30 * 3_600L + val skinAnchorScanFrom = nowLocalMidnight - (maxDays - 1).toLong() * SECONDS_PER_DAY - StreamReadCap.LOOKBACK_SECONDS val skinAnchorScanTo = nowLocalMidnight + 18 * 3_600L val skinAnchorByOwner = HashMap() val skinAnchorResolvedOwners = HashSet() @@ -728,6 +750,7 @@ object IntelligenceEngine { // produced under one method is stale the moment the user switches — serving it would show a // window of days scored by a recipe the user just turned off, with nothing to explain it. effortMethod.toString(), + dayCycleMode.persistedValue, ).joinToString("|") // Drop the whole cache on a config change. Under [analyzeGate] (this whole pass runs holding the // lock), so mutating the object-level cache here is race-free. @@ -746,6 +769,15 @@ object IntelligenceEngine { // Together with [dayCacheReused] this is the honest denominator for the reuse ratio — see the // diagnostic at the end of the loop. var dayCacheCacheable = 0 + // #1538: backward sliding read buffers for the two heavy streams. Pass 1 walks backwards over + // 54-hour windows on a 24-hour stride, so consecutive days overlap by 30 hours and every row was + // being materialised ~2.25x per pass. These read the missing stride only; every case the planner + // cannot prove safe falls back to exactly the read that shipped before them. HR and R-R only: + // ~86k and ~54k rows a night against thousands for the other eight streams, so this is nearly all + // of the win for two call sites of blast radius. + val hrWindow = hrReadWindow(repo) + val rrWindow = rrReadWindow(repo) + // #1005: memoise the UN-coalesced registered WHOOP family per owner (null = non-WHOOP → never // cached). Kept separate from [skinFamilyByOwner] (which coalesces unknown → WHOOP5 for the skin // scale); this must NOT coalesce so a ring can't be cached as a WHOOP. @@ -761,7 +793,7 @@ object IntelligenceEngine { val dayDiagLines = ArrayList() fun dayDiag(line: String) { dayDiagLines.add(line); diag(line) } // Read a generous window around the night that ends on `day`; the stager finds the span. - val from = dayStart - 30 * 3_600L + val from = dayStart - StreamReadCap.LOOKBACK_SECONDS // Sleep read-window END — see `sleepReadWindowEnd`. A PAST day reads through to the next // local midnight so the stager sees the whole night; TODAY is capped at `now` (never read // the future), NOT a fixed `dayStart + 18h` — that cap reported a flat 18:00 wake for a @@ -802,13 +834,21 @@ object IntelligenceEngine { // per-day anchor block below is a no-op — byte-identical anchor either way. A 5/MG banks // skin-temp centidegrees directly — no per-device anchor — so its anchor slot stays null. if (cacheOwnerFamily == DeviceFamily.WHOOP4 && !skinAnchorResolvedOwners.contains(owner)) { - val windowSkin = repo.skinTempSamples(owner, skinAnchorScanFrom, skinAnchorScanTo, STREAM_LIMIT) + val windowSkin = repo.skinTempSamples(owner, skinAnchorScanFrom, skinAnchorScanTo, + StreamReadCap.SKIN) Whoop4SkinTemp.deviceAnchorRaw(windowSkin.map { it.raw })?.let { skinAnchorByOwner[owner] = it } skinAnchorResolvedOwners.add(owner) } val (fpCount, fpMaxTs) = repo.hrFingerprintWindow(owner, from, to) val key = AnalyzeRecentDayCache.cacheKey( owner, fpCount, fpMaxTs, skinAnchorByOwner[owner], + // #29: the OTHER scored streams for this night. Without it a night whose R-R (or + // resp/SpO2) landed after its HR keys identically to the HR-only scan it was scored + // from, and the HRV-less result is re-served for the rest of the process — a + // user-initiated refresh included, since that only bypasses the whole-pass watermark + // gate, never this one. Both reads are index-only aggregates over the same + // (deviceId, ts) keys; a miss costs the full stream reads this gate exists to skip. + streams = repo.dayStreamFingerprint(owner, from, to), // #1575: `&& hrvTraceSink != null` matters. With the HRV trace OFF no detail line // is ever produced, so the flag describes nothing — but it would still flip at // midnight and invalidate yesterday, charging EVERY user an extra day's re-score to @@ -848,7 +888,7 @@ object IntelligenceEngine { // itself end to end, so whether the per-night cost is store reads or analyzeDay is unmeasured, // and that split decides whether narrowing the read windows is worth building. val tPrep0 = System.nanoTime() - val hr = repo.hrSamples(owner, from, to, STREAM_LIMIT) + val hr = hrWindow.rows(owner, from, to) // CAPTURE-B: capture this day's resolved read owner + HR-row count so PASS 2 can emit the // verbatim universal `dayOwner …` line per SCORED day (matching the iOS emit, which is in the // scored-days loop, NOT here). Only when the universal sink is on. A day skipped below for too @@ -875,7 +915,7 @@ object IntelligenceEngine { skippedSleepDays.add(day, hr.size) continue } - val rr = repo.rrIntervals(owner, from, to, STREAM_LIMIT) + val rr = rrWindow.rows(owner, from, to) // ONE read, TWO consumers, and they must not be confused for each other. `forScoring` strips // an Oura ring's rows from the STAGER's input: the stager reads this stream as a ~1 Hz raw ADC // waveform and peak-detects it, and the ring's rows are a per-window RATE — the wrong shape, @@ -886,52 +926,18 @@ object IntelligenceEngine { val respRows = repo.respSamples(owner, from, to, STREAM_LIMIT) val resp = OuraRespScale.forScoring(respRows, owner) val vendorResp = OuraRespScale.forVendorRate(respRows, owner) - val grav = repo.gravitySamples(owner, from, to, STREAM_LIMIT) + val grav = repo.gravitySamplesForDevice(owner, from, to, StreamReadCap.GRAVITY) val steps = repo.stepSamples(owner, from, to, STREAM_LIMIT) - val skin = repo.skinTempSamples(owner, from, to, STREAM_LIMIT) - // #93: WHOOP 4.0 raw SpO2 PPG samples for the night; analyzeDay banks the nightly red/IR ADC - // means on the DailyMetric. Empty on a 5/MG (no v24 spo2 channels) → the raw means stay null. - val spo2 = repo.spo2Samples(owner, from, to, STREAM_LIMIT) - // #938: the strap family that WROTE this owner's skin-temp rows, so analyzeDay converts the raw - // register on the right scale (5/MG banks centidegrees, a WHOOP 4.0 v24 banks a raw ADC). The - // owner source resolves it from the registry; unknown/non-WHOOP owners fall back to WHOOP5 (the - // prior /100 behaviour), so only a device positively identified as a 4.0 changes scale. - // Resolved once per DISTINCT owner via [skinFamilyByOwner] (#970 read efficiency, see above). - val skinFamily = skinFamilyByOwner.getOrPut(owner) { - ownerSource?.skinTempFamily(owner) ?: DeviceFamily.WHOOP5 - } - // #1467: the worn-gate timestamp tolerance for this owner (0 for WHOOP, byte-identical). - val skinWornToleranceSec = skinWornToleranceByOwner.getOrPut(owner) { - ownerSource?.skinTempWornToleranceSec(owner) ?: 0 - } - // #938 (second capture): learn THIS device's worn skin-temp anchor raw ONCE, WINDOW-WIDE (the - // whole scan window's skin samples), not per-night. The @72 skin-temp ADC's register offset is - // per-device — a second real 4.0 strap shares the no-contact floor (~509) + 11-bit saturation - // (2047) but a worn band ~1100–1600 (nightly mean raw ~1290), which the global 826 anchor maps to - // 47–72 °C, so 100% of its worn samples fail the 28–42 °C gate (kept=0, no baseline, no signal). - // WINDOW-WIDE, not per-night: a per-night re-centre would subtract each night's own mean and ERASE - // the cross-night deviation the skinTempDevC signal exists to carry. Deterministic per run; SAFE - // because the skin baseline is re-folded from the SAME window's nightly means every run, so this - // constant offset cancels in the deviation. null for a non-4.0 owner (WHOOP5 ignores the anchor) - // or when <100 in-band samples exist → the conversion falls back to the global anchor (byte- - // identical to today). Computed here once per owner alongside the family resolution. - val skinAnchorRaw = if (skinFamily == DeviceFamily.WHOOP4) { - if (!skinAnchorResolvedOwners.contains(owner)) { - val windowSkin = repo.skinTempSamples(owner, skinAnchorScanFrom, skinAnchorScanTo, STREAM_LIMIT) - Whoop4SkinTemp.deviceAnchorRaw(windowSkin.map { it.raw })?.let { skinAnchorByOwner[owner] = it } - skinAnchorResolvedOwners.add(owner) - } - skinAnchorByOwner[owner] - } else { - null - } - // Wrist-wear events in the night window, paired into off-wrist [start, end) intervals for the - // off-wrist sleep backstop (#500). The HR-gap proxy in the stager is the always-on guard; - // these explicit intervals sharpen it under the FRACTIONAL rule (#504) , a session is dropped - // only when its off-wrist coverage reaches maxOffWristSleepFraction, so a real night with a - // short off-wrist tail survives. Pairing needs WRIST_ON too (to bound each interval); a span - // still open at the window end closes at `to`. Empty when the strap emitted no wrist events. - val wristOff = AnalyticsEngine.offWristIntervals(repo.events(owner, from, to, STREAM_LIMIT), to) + val skinReads = readDaySkinAndWristOff( + repo, owner, from, to, ownerSource, skinFamilyByOwner, skinWornToleranceByOwner, + skinAnchorByOwner, skinAnchorResolvedOwners, skinAnchorScanFrom, skinAnchorScanTo, + ) + val skin = skinReads.skin + val spo2 = skinReads.spo2 + val skinFamily = skinReads.skinFamily + val skinWornToleranceSec = skinReads.skinWornToleranceSec + val skinAnchorRaw = skinReads.skinAnchorRaw + val wristOff = skinReads.wristOff // Calendar-day window for the ADDITIVE daily totals (steps + calories). The night window // above is anchored to the current time-of-day and ends at dayStart+12h, so for a PAST @@ -951,7 +957,7 @@ object IntelligenceEngine { // TODAY (dayEnd past the 18 h cap) and a limit-truncated night read DECLINE (null) → direct // read, so the shortcut only ever skips work, never changes data. Twin of Swift's #997. val dayHr = AnalyticsEngine.daySliceFromNight(hr, from, to, dayMidnight, dayEnd) { it.ts.toLong() } - ?: repo.hrSamples(owner, dayMidnight, dayEnd, STREAM_LIMIT) + ?: repo.hrSamplesForDevice(owner, dayMidnight, dayEnd, STREAM_LIMIT) val daySteps = AnalyticsEngine.daySliceFromNight(steps, from, to, dayMidnight, dayEnd) { it.ts } ?: repo.stepSamples(owner, dayMidnight, dayEnd, STREAM_LIMIT) // Full calendar-day gravity for WORKOUT detection. For a PAST day the night window runs to the @@ -959,7 +965,7 @@ object IntelligenceEngine { // directly, which the slice's `dayHi > nightHi` guard handles — a 5 pm run still shows up the // same day. val dayGrav = AnalyticsEngine.daySliceFromNight(grav, from, to, dayMidnight, dayEnd) { it.ts } - ?: repo.gravitySamples(owner, dayMidnight, dayEnd, STREAM_LIMIT) + ?: repo.gravitySamplesForDevice(owner, dayMidnight, dayEnd, STREAM_LIMIT) // CONSUME (#531 / #175): the strap's OWN band sleep_state for the night window as (ts, state) // samples, so the H7 morning-stillness guard can confirm a borderline re-onset against the strap's @@ -981,18 +987,68 @@ object IntelligenceEngine { // scores. Gated on absent gravity (`grav.size < 2` — a ring streams zero; a WHOOP always streams a // gravity vector) plus a non-canonical-WHOOP-import owner, so WHOOP straps and the "my-whoop" // import namespace are untouched; analyzeDay still lets a DETECTED session win where they overlap. - val providedSleep: List = - if (owner != importedDeviceId && grav.size < 2) { - repo.sleepSessions(owner, from, to, 4000) - .mapNotNull { AnalyticsEngine.sleepSessionFromProvided(it) } - } else { - emptyList() + // #804 Fix A + #1801. Two different questions share the "this day has no motion" gate. + // + // #804 hands over a device's OWN persisted hypnogram (an Oura ring's SleepNet night), and is + // deliberately not applied to the import namespace. #1801 stages from heart rate when there is + // no hypnogram at all — and that one must NOT inherit #804's owner exclusion, which is the bug + // this replaces: `resolveDayOwner` returns [importedDeviceId] whenever the owner source is + // absent or the candidates collapse to it, so on a live 5/MG install the whole branch was + // skipped before any heart rate was looked at. The field log said so outright once the gate + // line existed: `attempted=false reason=imported-owner grav=0`, on a day holding 165,980 HR + // rows. A condition written to exclude WHOOP straps was guarding a fallback FOR one. + // + // The stored lookup now runs for every no-motion day, so "nothing else knows about this + // night" is checked rather than assumed. A day that HAS stored sessions is left alone whoever + // owns it: inferring a night from heart rate when the device recorded a real one would be + // strictly worse evidence replacing better. + val providedSleep: List = if (grav.size < 2) { + val stored = repo.sleepSessionsForDevice(owner, from, to, 4000) + .mapNotNull { AnalyticsEngine.sleepSessionFromProvided(it) } + when { + owner != importedDeviceId && stored.isNotEmpty() -> { + dayDiag(SleepStagerTrace.hrOnlyGateLine( + attempted = false, reason = "stored-hypnogram", + gravRows = grav.size, storedNights = stored.size, + )) + stored + } + stored.isNotEmpty() -> { + // The import namespace keeps #804's exclusion — its rows are not handed to + // analyzeDay as "provided" — but they still mean this night is already known, + // so the heart-rate fallback stays out of it. + dayDiag(SleepStagerTrace.hrOnlyGateLine( + attempted = false, reason = "stored-sessions-exist", + gravRows = grav.size, storedNights = stored.size, + )) + emptyList() + } + else -> { + // Reachable for ANY owner now, which widens this past the 5/MG it was built for: + // a WHOOP 4.0 day that banked nothing at all (`grav.size < 2`, no stored night) + // also lands here, where the owner check previously blocked it. A normal 4.0 day + // is untouched — it streams gravity, so it never reaches this gate — and a day + // with no motion has too little of anything to clear `minSleepMin`, but "too + // little" is not "none", so the night it could produce is marked + // [DetectedSleep.hrOnly] like every other. + dayDiag(SleepStagerTrace.hrOnlyGateLine( + attempted = true, reason = "no-motion-no-hypnogram", + gravRows = grav.size, storedNights = 0, + )) + SleepStager.hrOnlySessions(hr, rr, resp, traceSink = ::dayDiag) + } } + } else { + emptyList() + } val tScore0 = System.nanoTime() dayPrepNanos += tScore0 - tPrep0 val res = AnalyticsEngine.analyzeDay( day = day, + // #1770 follow-up: route the Effort funnel through the SAME per-day recorder as the + // `workout detect` and `sleep-detect` lines, so a report explains all three the same way. + strainDiag = ::dayDiag, hr = hr, rr = rr, resp = resp, @@ -1084,9 +1140,13 @@ object IntelligenceEngine { // 1.25x its wall-clock reads ~197 ms across a sleeping night, against a 40-100 ms // physiological range. Printing that number beside the verdict that says it cannot be // trusted invites it to be read as a measurement, so it is withheld instead; the - // `rrIntegrity=` field on the same line says why. RMSSD/meanNN are NOT withheld — mean rate - // survives an over-count, and RMSSD's dominant error was the emission order fixed at the - // write path (#1072). Twin of the Swift line. + // `rrIntegrity=` field on the same line says why. meanNN is NOT withheld: mean rate + // survives an over-count. RMSSD is not withheld FROM THIS LINE either, but it no longer + // reaches the card, the daily row or the baseline on an over-counted night — the gate that + // stops it lives in `SleepStager.sessionAvgHRV` (#1118). Printing the computed value here + // while the app refused to use it is a known wart: matching SDNN's `withheld` treatment + // needs room this method does not have, since its JaCoCo budget is already at the ratchet. + // Twin of the Swift line. // P7' follow-up: the over-count verdict is necessary but NOT sufficient. The 2026-08-06 // Oura night measured coverage 1.03 / PLAUSIBLE — no duplication at all, its records // tiling the timeline at a fill ratio of 0.990 — and still printed SDNN 174 ms. A BANKED @@ -1189,7 +1249,7 @@ object IntelligenceEngine { sleepDetectNoNightLogLine( day = day, hrCount = hr.size, rrCount = rr.size, respCount = resp.size, gravCount = grav.size, stepCount = steps.size, providedCount = providedSleep.size, - windowHours = windowHours, + windowHours = windowHours, skinCount = skin.size, ), ) } @@ -1228,12 +1288,7 @@ object IntelligenceEngine { // in-bed span the floor came from (so they're directly comparable); a night with no banked // floor (no matched sleep) logs nil and the line is skipped. Logging only , no scoring change. // Counts/bpm only; no timestamps or PII (the diag sink also scrubs). Byte-identical to Swift. - val rhrFloor = res.daily.restingHr - if (rhrFloor != null) { - val inBedBpms = hr.filter { s -> res.sleepSessions.any { s.ts >= it.start && s.ts < it.end } } - .map { it.bpm } - dayDiag(rhrFloorMeanLogLine(day, rhrFloor, inBedBpms)) - } + for (l in rhrDiagLines(day, res.daily.restingHr, hr, res.sleepSessions)) dayDiag(l) // #103/queue-11a: SpO₂ candidate nightly mean. Only computed when the display toggle is ON, // and the transform is device-conditional (com.noop.data.DeviceBrandCatalog.isOura, same // idiom OuraRespScale.isRingRateStream uses): a WHOOP owner averages the in-band (70–100) @@ -1302,6 +1357,12 @@ object IntelligenceEngine { // (each row materialised ~2.25x per pass) is worth narrowing; analyzeDay dominating means it is // not, whatever the row counts look like. Byte-identical line to the Swift twin. diag("analyzeRecent cost prep=${dayPrepNanos / 1_000_000}ms score=${dayScoreNanos / 1_000_000}ms") + diag( + WindowedStreamPlan.logLine( + hrWindow.rowsRead, hrWindow.rowsServed, hrWindow.truncatedReads, + rrWindow.rowsRead, rrWindow.rowsServed, rrWindow.truncatedReads, + ), + ) // ── Seed the baseline from the UNION of imported nightly history + the nightly // values just computed. This is the recovery fix: the "-noop" nightly avgHrv/ @@ -1396,7 +1457,7 @@ object IntelligenceEngine { // the cross-source duplicate (#107): the strap source carries imported WHOOP rows AND manual / // re-labelled rows (both under [importedDeviceId]); apple-health / health-connect carry Health // imports , a detected bout overlapping ANY of them is skipped below. - val windowStart = nowSeconds - maxDays.toLong() * SECONDS_PER_DAY - 30 * 3_600L + val windowStart = nowSeconds - maxDays.toLong() * SECONDS_PER_DAY - StreamReadCap.LOOKBACK_SECONDS val realWorkouts = repo.workouts(importedDeviceId, windowStart, nowSeconds) + repo.workouts("apple-health", windowStart, nowSeconds) + repo.workouts("health-connect", windowStart, nowSeconds) @@ -1458,6 +1519,13 @@ object IntelligenceEngine { .appleDaily(WhoopRepository.APPLE_HEALTH_SOURCE, "0000-01-01", "9999-12-31") .map { it.day }.toHashSet() + val physiologicalSteps = DayCycleIntelligenceIntegration.compute( + scoredNights, editedRows, resolvedScoreOwnerByDay, candidatePriorities, repo, + tzOffsetSeconds, habitualMidsleepSec, windowStart, nowSeconds, profile.stepTicksPerStep, + stepsTraceSink, dayCycleMode, + profile, maxHROverride, effortMethod, + ) + for (res in scoredNights) { // #299: scope the edits to THIS day before folding. A userEdited row / hand-logged nap belongs // to exactly ONE day — the day its night ENDS on, matching the daily's end-day bucket @@ -1467,13 +1535,11 @@ object IntelligenceEngine { // #547 effective-onset detail is preserved: editOnsetByStart still carries the user-CORRECTED // bedtime (startTsAdjusted ?: startTs) for this day's edited/manual blocks. val dayEditedRows = editedRowsForDay(editedRows, res.daily.day, tzOffsetSeconds) - val editsByStart: Map = dayEditedRows.associate { it.startTs to it.stagesJSON } - val editOnsetByStart: Map = dayEditedRows.associate { it.startTs to it.effectiveStartTs } // Substitute an edited block's (reshaped) stages for its detected twin before the daily // sleep aggregate feeds Rest + recovery. No edit touching this night → `daily` is unchanged. - val daily = sleepEditedDaily( - res.daily, res.sleepSessions, editsByStart, editOnsetByStart, - tzOffsetSeconds, habitualMidsleepSec, + val daily = editedCycleDaily( + res, dayEditedRows, tzOffsetSeconds, habitualMidsleepSec, + physiologicalSteps, computedId, restRows, ) val recovery = recomputeRecovery(daily, baselines2) // Charge term-breakdown trace (Test Centre Group G): only when the Recovery test mode is on @@ -1549,8 +1615,9 @@ object IntelligenceEngine { // Scoped to computed days: an imported-total-only day legitimately has a total without our // sessions, so it is NOT a divergence. val imported = daily.day in importedWhoopDays || daily.day in appleHealthDays - if (!imported && daily.totalSleepMin != null && res.sleepSessions.isEmpty()) { - diag(sleepDivergenceLogLine(daily.day, Math.round(daily.totalSleepMin).toInt(), dayEditedRows.size)) + val finalSleepMin = daily.totalSleepMin + if (!imported && finalSleepMin != null && res.sleepSessions.isEmpty()) { + diag(sleepDivergenceLogLine(daily.day, Math.round(finalSleepMin).toInt(), dayEditedRows.size)) } // #195: one always-on line per scored night with the computed HRV value + the window it used, // so an "HRV reads high / deep-sleep window not changing" report is self-diagnosing straight @@ -1756,31 +1823,11 @@ object IntelligenceEngine { // this only fills the days the strap collected but no import covered. // Persist metric-level input provenance in the SAME Room transaction. dayOwnership remains // exclusively a resolver override, and a failed write can never relabel an older score. - val provenanceByCell = LinkedHashMap, ScoreInputProvenanceRow>() - for (daily in dailies) { - val source = resolvedScoreOwnerByDay[daily.day] ?: continue - if (daily.recovery != null) { - provenanceByCell[daily.day to "recovery"] = - ScoreInputProvenanceRow(computedId, daily.day, "recovery", source) - } - if (daily.strain != null) { - provenanceByCell[daily.day to "strain"] = - ScoreInputProvenanceRow(computedId, daily.day, "strain", source) - } - } - for (point in restRows) { - val source = resolvedScoreOwnerByDay[point.day] ?: continue - provenanceByCell[point.day to point.key] = - ScoreInputProvenanceRow(computedId, point.day, point.key, source) - } - repo.replaceComputedScoreWindow( - deviceId = computedId, - from = oldestDay, - to = newestDay, - dailyMetrics = dailies, - metricPoints = restRows, - provenance = provenanceByCell.values.toList(), + val computedWindow = IntelligencePersistence.prepareComputedWindow( + repo, importedDeviceId, computedId, oldestDay, newestDay, dailies, restRows, physiologicalSteps, + candidatePriorities, resolvedScoreOwnerByDay, ) + repo.replaceComputedScoreWindow(computedWindow) persistFitnessVitalityAndSteps( repo = repo, @@ -1819,37 +1866,8 @@ object IntelligenceEngine { val dismissedWindows = repo.dismissedSleeps(importedDeviceId).map { it.startTs to it.endTs } val skipWindows = editedWindows + dismissedWindows val sleepKept = DismissedSleepGuard.keeping(sleepRows, skipWindows) { it.startTs to it.endTs } - if (sleepKept.isNotEmpty()) repo.upsertSleepSessions(sleepKept) - // ── Persist per-epoch motion (H8) beside each kept session's stagesJSON ────────────────────────── - // The sleepSession rows exist now (just upserted), so the targeted motion UPDATE lands. Persist ONLY - // for the sessions actually kept (not edited/dismissed), keyed by the detected start analyzeDay - // returned. A session whose gravity wouldn't grid was omitted from the map and is left as NULL , an - // absent motion series stays absent, never a fabricated zero array. Mirrors Swift. + IntelligencePersistence.persistDetectedSleepDetails(repo, computedId, sleepKept, scoredNights) val keptStarts = sleepKept.map { it.startTs }.toHashSet() - val motionByStart = HashMap>() - for (res in scoredNights) { - for ((start, motion) in res.sessionMotionByStart) { - if (start in keptStarts) motionByStart[start] = motion - } - } - for ((start, motion) in motionByStart) { - repo.persistSessionMotion(computedId, start, motion) - } - // ── Persist per-epoch BAND sleep_state (#175) beside each kept session's stagesJSON ────────────── - // This is the source `sleepStateJSON` lacked (the write path had no producer because the raw stream - // was dropped at extraction). Now analyzeDay grids the RAW `sleepStateSample` stream per session; - // persist it here so the NEXT pass's bandSleepStateSamples read (the H7 confirm) and the display can - // see the strap's OWN scored band. ONLY for kept (not edited/dismissed) sessions; a session with no - // band samples was omitted (no key) and stays NULL — an absent signal stays absent. Mirrors Swift. - val sleepStateByStart = HashMap>() - for (res in scoredNights) { - for ((start, states) in res.sessionSleepStateByStart) { - if (start in keptStarts) sleepStateByStart[start] = states - } - } - for ((start, states) in sleepStateByStart) { - repo.persistSessionSleepState(computedId, start, states) - } // ── Overlap-aware banked-sleep heal (#899) ──────────────────────────────────────────────────── // An unstable strap clock re-banks the SAME night under a shifted timebase, so successive passes // detect it at shifted bounds and the upsert above lands a SECOND row beside the stale one (the @@ -1881,7 +1899,7 @@ object IntelligenceEngine { } val healDropped = ArrayList() for (healId in healDeviceIds) { - val storedSessions = repo.sleepSessions(healId, windowStart, nowSeconds, 4000) + val storedSessions = repo.sleepSessionsForDevice(healId, windowStart, nowSeconds, 4000) val healable = storedSessions.filter { AnalyticsEngine.dayString(it.endTs, tzOffsetSeconds) in oldestDay..newestDay } @@ -2035,10 +2053,16 @@ object IntelligenceEngine { val dayEnd = dayMid + SECONDS_PER_DAY - 1 val dayKey = AnalyticsEngine.dayString(dayMid, tzOffsetSeconds) val owner = resolveDayOwner(repo, ownerSource, candidatePriorities, dayKey, dayMid, dayEnd, importedDeviceId) - val grav = repo.gravitySamples(owner, dayMid, dayEnd, STREAM_LIMIT) + val grav = repo.gravitySamplesForDevice(owner, dayMid, dayEnd, STREAM_LIMIT) val m = StepsEstimateEngine.dayMotionIntensity(grav) if (m > 0) motionByDay[dayKey] = m } + // #1816: persist whether the strap banked ANY motion in the calibration scan window, so the Today + // tile can distinguish "Need N more phone-step days" (motion exists, phone half missing) from + // "No motion synced yet" (the motion half is the blocker, and no number of phone-step days will + // move the estimate or the fit). A step estimate is `motion * coefficient`, so with the motion + // half missing the caption that names only the phone half is a lie. + stepsHasMotionSink?.invoke(motionByDay.isNotEmpty()) // Build calibration points only for days with BOTH a motion volume and a real phone step count. val calPoints = motionByDay.mapNotNull { (day, motion) -> refStepsByDay[day]?.let { StepsEstimateEngine.CalibrationPoint(motion = motion, steps = it) } @@ -2144,7 +2168,7 @@ object IntelligenceEngine { // merged-workout case, where kcal is the SUM of inputs so it never looks under-scored yet // Effort stays blank forever). improves() then accepts a strain-only gain for the latter. if (!ManualWorkoutRescore.looksUnderScored(row.energyKcal) && row.strain != null) continue - val samples = runCatching { repo.hrSamples(deviceId, row.startTs, row.endTs, 20_000) } + val samples = runCatching { repo.hrSamplesForDevice(deviceId, row.startTs, row.endTs, 20_000) } .getOrNull() ?: continue val s = ManualWorkoutRescore.scored( samples, profile, hrMax, restingHR, effortMethod) ?: continue @@ -2310,7 +2334,7 @@ object IntelligenceEngine { // re-banked copy of the night would otherwise feed "asleep" epochs at the OLD times into the H7 // re-onset guard, letting the stale block keep confirming itself. Read-side only (no bank-recency // witness here); the store itself is healed post-upsert in analyzeRecentOnCpu. Mirrors Swift. - val sessions = SleepSessionDedup.dedupe(repo.sleepSessions(computedId, from, to, 4000)).kept + val sessions = SleepSessionDedup.dedupe(repo.sleepSessionsForDevice(computedId, from, to, 4000)).kept val samples = ArrayList>() for (s in sessions) { val states = repo.sessionSleepState(computedId, s.startTs) ?: continue @@ -2338,8 +2362,8 @@ object IntelligenceEngine { windowEnd: Long, offsetSec: Long, ): Pair> { - val imported = repo.sleepSessions(importedId, windowStart, windowEnd, 4000) - val computed = repo.sleepSessions(computedId, windowStart, windowEnd, 4000) + val imported = repo.sleepSessionsForDevice(importedId, windowStart, windowEnd, 4000) + val computed = repo.sleepSessionsForDevice(computedId, windowStart, windowEnd, 4000) // #899: collapse overlapping timebase-shifted duplicates BEFORE the learner sees the history. // A stale re-banked copy of a night lands on a DIFFERENT day key, so the per-day longest-block // de-dup below never caught it and the learned midsleep drifted toward the stale timing, which @@ -2400,6 +2424,24 @@ object IntelligenceEngine { tzOffsetSeconds: Long, ): List = editedRows.filter { AnalyticsEngine.dayString(it.endTs, tzOffsetSeconds) == day } + private fun editedCycleDaily( + result: DayResult, + edits: List, + tzOffsetSeconds: Long, + habitualMidsleepSec: Long?, + cycle: PhysiologicalStepCycleEngine.Result, + computedId: String, + metricRows: MutableList, + ): DailyMetric { + val editsByStart = edits.associate { it.startTs to it.stagesJSON } + val onsetByStart = edits.associate { it.startTs to it.effectiveStartTs } + val edited = sleepEditedDaily( + result.daily, result.sleepSessions, editsByStart, onsetByStart, + tzOffsetSeconds, habitualMidsleepSec, + ) + return DayCycleIntelligenceIntegration.apply(edited, cycle, computedId, metricRows) + } + private fun sleepEditedDaily( daily: DailyMetric, detected: List, @@ -2618,7 +2660,7 @@ object IntelligenceEngine { val candidates = candidatePriorities.map { (id, priority) -> // Cheap presence check: a single HR row for this device in the night window marks it a // candidate. (LIMIT 1 , not the full pull the caller does once an owner is chosen.) - val hasData = repo.hrSamples(id, from, to, 1).isNotEmpty() + val hasData = repo.hrSamplesForDevice(id, from, to, 1).isNotEmpty() DayOwnerResolver.Candidate(deviceId = id, priority = priority, hasData = hasData) } return DayOwnerResolver.resolve(day, lockedOwner = null, candidates = candidates) ?: importedDeviceId @@ -2671,6 +2713,112 @@ object IntelligenceEngine { return if (dayStart < nowLocalMidnight) nextMidnight else minOf(nextMidnight, now) } + /** The pass-1 HR sliding read window. Constructed OUTSIDE `analyzeRecentOnCpu` so neither the + * element lambda nor the reader lambda counts against that method's bytecode budget, which the + * extraction next door exists to protect. */ + private fun hrReadWindow(repo: com.noop.data.WhoopRepository) = + SlidingStreamWindow({ it.ts }, StreamReadCap.HR) { o, f, t -> + repo.hrSamplesForDevice(o, f, t, StreamReadCap.HR) + } + + /** The pass-1 R-R sliding read window. Same reason as [hrReadWindow] for living out here. */ + private fun rrReadWindow(repo: com.noop.data.WhoopRepository) = + SlidingStreamWindow({ it.ts }, StreamReadCap.RR) { o, f, t -> + repo.rrIntervalsForDevice(o, f, t, StreamReadCap.RR) + } + + + /** + * The per-day skin-temp, SpO2 and off-wrist reads, lifted out of `analyzeRecentOnCpu` (#1538). + * + * Nothing about this block changed; it moved. The method it came from sits 17 bytes under the JaCoCo + * budget its own guard pins, so it could not accept another line — and the established remedy in this + * file is to extract, not to raise the budget (see `persistFitnessVitalityAndSteps`, extracted for the + * same reason and pinned in place by its own test). + * + * The per-owner memo maps are passed in and MUTATED here, exactly as they were inline: the WHOOP 4.0 + * ADC anchor is a property of the device rather than the night, so it is learned once per owner across + * the whole scan window and reused for every night. Moving that behind a function does not change when + * it is learned or what it is learned from. + * + * DELIBERATELY ONE-SIDED — do not mirror it. The Swift engine keeps this block inline, because the + * constraint that forced the extraction is a JVM one: a method's bytecode must fit 64 KB, and JaCoCo's + * instrumentation of it must too. Swift has no equivalent limit and no equivalent guard, so a twin + * helper there would buy nothing and cost a reader the question of what it was for. A parity audit + * that finds this with no Swift counterpart has found the intended state, not a gap. + */ + private suspend fun readDaySkinAndWristOff( + repo: com.noop.data.WhoopRepository, + owner: String, + from: Long, + to: Long, + ownerSource: DayOwnerSource?, + skinFamilyByOwner: HashMap, + skinWornToleranceByOwner: HashMap, + skinAnchorByOwner: HashMap, + skinAnchorResolvedOwners: HashSet, + skinAnchorScanFrom: Long, + skinAnchorScanTo: Long, + ): DaySkinReads { + val skin = repo.skinTempSamples(owner, from, to, StreamReadCap.SKIN) + // #93: WHOOP 4.0 raw SpO2 PPG samples for the night; analyzeDay banks the nightly red/IR ADC + // means on the DailyMetric. Empty on a 5/MG (no v24 spo2 channels) → the raw means stay null. + val spo2 = repo.spo2Samples(owner, from, to, STREAM_LIMIT) + // #938: the strap family that WROTE this owner's skin-temp rows, so analyzeDay converts the raw + // register on the right scale (5/MG banks centidegrees, a WHOOP 4.0 v24 banks a raw ADC). The + // owner source resolves it from the registry; unknown/non-WHOOP owners fall back to WHOOP5 (the + // prior /100 behaviour), so only a device positively identified as a 4.0 changes scale. + // Resolved once per DISTINCT owner via [skinFamilyByOwner] (#970 read efficiency, see above). + val skinFamily = skinFamilyByOwner.getOrPut(owner) { + ownerSource?.skinTempFamily(owner) ?: DeviceFamily.WHOOP5 + } + // #1467: the worn-gate timestamp tolerance for this owner (0 for WHOOP, byte-identical). + val skinWornToleranceSec = skinWornToleranceByOwner.getOrPut(owner) { + ownerSource?.skinTempWornToleranceSec(owner) ?: 0 + } + // #938 (second capture): learn THIS device's worn skin-temp anchor raw ONCE, WINDOW-WIDE (the + // whole scan window's skin samples), not per-night. The @72 skin-temp ADC's register offset is + // per-device — a second real 4.0 strap shares the no-contact floor (~509) + 11-bit saturation + // (2047) but a worn band ~1100–1600 (nightly mean raw ~1290), which the global 826 anchor maps to + // 47–72 °C, so 100% of its worn samples fail the 28–42 °C gate (kept=0, no baseline, no signal). + // WINDOW-WIDE, not per-night: a per-night re-centre would subtract each night's own mean and ERASE + // the cross-night deviation the skinTempDevC signal exists to carry. Deterministic per run; SAFE + // because the skin baseline is re-folded from the SAME window's nightly means every run, so this + // constant offset cancels in the deviation. null for a non-4.0 owner (WHOOP5 ignores the anchor) + // or when <100 in-band samples exist → the conversion falls back to the global anchor (byte- + // identical to today). Computed here once per owner alongside the family resolution. + val skinAnchorRaw = if (skinFamily == DeviceFamily.WHOOP4) { + if (!skinAnchorResolvedOwners.contains(owner)) { + val windowSkin = repo.skinTempSamples(owner, skinAnchorScanFrom, skinAnchorScanTo, + StreamReadCap.SKIN) + Whoop4SkinTemp.deviceAnchorRaw(windowSkin.map { it.raw })?.let { skinAnchorByOwner[owner] = it } + skinAnchorResolvedOwners.add(owner) + } + skinAnchorByOwner[owner] + } else { + null + } + // Wrist-wear events in the night window, paired into off-wrist [start, end) intervals for the + // off-wrist sleep backstop (#500). The HR-gap proxy in the stager is the always-on guard; + // these explicit intervals sharpen it under the FRACTIONAL rule (#504) , a session is dropped + // only when its off-wrist coverage reaches maxOffWristSleepFraction, so a real night with a + // short off-wrist tail survives. Pairing needs WRIST_ON too (to bound each interval); a span + // still open at the window end closes at `to`. Empty when the strap emitted no wrist events. + val wristOff = AnalyticsEngine.offWristIntervals(repo.events(owner, from, to, STREAM_LIMIT), to) + return DaySkinReads(skin, spo2, skinFamily, skinWornToleranceSec, skinAnchorRaw, wristOff) + } + + /** What [readDaySkinAndWristOff] hands back. A holder rather than loose returns so the call site + * re-binds the same names it used inline and the rest of the loop is untouched. */ + private data class DaySkinReads( + val skin: List, + val spo2: List, + val skinFamily: DeviceFamily, + val skinWornToleranceSec: Long, + val skinAnchorRaw: Double?, + val wristOff: List>, + ) + /** * The per-day diagnostic source token from the imported day-key sets. A WHOOP export covering [day] * WINS the dashboard merge over our computed row (imports win field-by-field , mergeDaily), so it @@ -2782,6 +2930,31 @@ object IntelligenceEngine { } } + /** + * The night's resting-HR diagnostic lines: the floor-vs-mean explainer, and the #1943 + * conformance line beside it when the gate and the shipped floor disagree. + * + * Built here rather than inline in `analyzeRecentOnCpu` because that method is BUDGETED: + * [IntelligenceEngineJacocoBudgetTest] ratchets its JaCoCo-instrumented size against the JVM's method + * limit, and the budget is never raised to fit a change. Returning the lines as a list, rather than + * emitting each at the call site, is what pays for the second one: the caller loses the null check, + * both lambdas and the intermediate list. + */ + private fun rhrDiagLines( + day: String, + rhrFloor: Int?, + hr: List, + sessions: List, + ): List { + if (rhrFloor == null) return emptyList() + val inBedBpms = hr.filter { s -> sessions.any { s.ts >= it.start && s.ts < it.end } }.map { it.bpm } + val out = ArrayList(2) + out.add(rhrFloorMeanLogLine(day, rhrFloor, inBedBpms)) + SleepStager.rhrBinGateLogLine(day, sessions.map { it.start to it.end }, hr, rhrFloor) + ?.let { out.add(it) } + return out + } + /** * The per-day RHR floor-vs-mean diagnostic line (#691). NOOP's [floor] is the WHOOP-style resting * HR , the lowest SUSTAINED 5-min in-bed level (SleepStager picks the min 5-min rolling-mean HR per @@ -2813,10 +2986,54 @@ object IntelligenceEngine { */ internal fun sleepDetectNoNightLogLine( day: String, hrCount: Int, rrCount: Int, respCount: Int, gravCount: Int, - stepCount: Int, providedCount: Int, windowHours: Int, + stepCount: Int, providedCount: Int, windowHours: Int, skinCount: Int, ): String { + // `reason` names WHICH absence this is, because grav=0 is printed but its consequence is not. + // + // `no-motion` USED to mean "and therefore nothing further was attempted" — the stager had no + // HR-only fallback, so no quantity of HR could stage a night. Since #1801 it does: a day with no + // gravity now also runs [SleepStager.hrOnlySessions], so this line printing `no-motion` means the + // motion spine was absent AND heart rate alone did not yield a night either — too little of it in + // the sleep band, or a run that staged to nothing. That is a stronger statement than it used to + // be, and the follow-up it wants is different: no longer "this strap cannot", but "why did the + // HR-only spine find nothing here". + // + // With motion present the inputs were there and staging still produced nothing, which remains the + // case most worth investigating. + val reason = if (gravCount == 0) "no-motion" else "staged-none" + // #1118 follow-up: name any stream that came back AT its read cap. A read that returns exactly + // the limit is the definition of truncated everywhere else here (`full.count >= limit`), and it + // is the one thing a reader cannot infer from the counts alone - `grav=192698` looks healthy + // until you know the cap it is 96% of. + // + // GRAVITY is why this exists. HR and R-R ride a SlidingStreamWindow, which counts its own + // truncations; gravity is a plain read with no counter at all, so a night clipped of its newest + // motion staged badly and said nothing. That is the difference between "this release fixes your + // night" and "your offload never ran", and a log could not tell them apart. + // ONLY the plain reads. Gravity and skin are read whole, so a result of exactly the cap IS the + // truncation — the same `size >= limit` test used everywhere else here, and neither stream has a + // counter of its own, which is why this marker exists. + // + // HR and R-R are deliberately absent even though their counts are printed. They arrive through + // `SlidingStreamWindow.rows`, which returns `full.filter { ts in from..to }` — a SLICE of a read + // that usually spans more than this night. A spliced window that WAS truncated still hands back a + // slice under the cap, so the marker would silently fail to fire: a false negative on a line whose + // only value is that its absence means "not clipped". Their exact truncation count is already + // printed unconditionally once per pass by `WindowedStreamPlan.logLine` (`hrTruncated=`), so + // nothing is lost by declining to guess it per night. + // + // resp and steps are plain reads too, and equally judgeable, but stay unmarked on purpose: neither + // is a staging input, so clipping one cannot be the reason a night is missing. Their counts are + // printed as context. The marker answers "did a stream that could explain this get cut short", + // not "was every read complete". + val atCap = buildList { + if (gravCount >= StreamReadCap.GRAVITY) add("grav") + if (skinCount >= StreamReadCap.SKIN) add("skin") + } + val capNote = if (atCap.isEmpty()) "" else " atCap=${atCap.joinToString(",")}" return "sleep-detect day=$day NO-NIGHT hr=$hrCount rr=$rrCount resp=$respCount " + - "grav=$gravCount steps=$stepCount provided=$providedCount window=${windowHours}h" + "grav=$gravCount skin=$skinCount steps=$stepCount provided=$providedCount " + + "window=${windowHours}h reason=$reason$capNote" } /** diff --git a/android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt b/android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt new file mode 100644 index 0000000000..871bd0fa99 --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt @@ -0,0 +1,115 @@ +package com.noop.analytics + +import com.noop.data.DailyMetric +import com.noop.data.MetricSeriesRow +import com.noop.data.ScoreInputProvenanceRow +import com.noop.data.SleepSession +import com.noop.data.WhoopRepository + +/** Persistence-only helpers kept out of the already large scoring orchestrator. */ +internal object IntelligencePersistence { + data class ComputedWindow( + val deviceId: String, + val from: String, + val to: String, + val dailies: List, + val metricRows: List, + val provenance: List, + val markerSourceIds: List, + ) + + suspend fun prepareComputedWindow( + repo: WhoopRepository, + importedDeviceId: String, + computedId: String, + from: String, + to: String, + dailies: List, + metricRows: List, + cycle: PhysiologicalStepCycleEngine.Result, + candidatePriorities: List>, + ownerByDay: Map, + ): ComputedWindow { + val provenance = scoreProvenance(computedId, dailies, metricRows, ownerByDay) + return ComputedWindow( + deviceId = computedId, + from = from, + to = to, + dailies = dailies, + metricRows = (metricRows + cycle.recoveredOwnerMarkerRows) + .distinctBy { Triple(it.deviceId, it.day, it.key) }, + provenance = provenance, + markerSourceIds = DayCycleIntelligenceIntegration.markerRewriteSourceIds( + repo.computedSourceIds(importedDeviceId), + candidatePriorities.map { (owner, _) -> repo.computedDeviceId(owner) }, + ), + ) + } + + fun scoreProvenance( + computedId: String, + dailies: List, + metricRows: List, + ownerByDay: Map, + ): List { + val byCell = LinkedHashMap, ScoreInputProvenanceRow>() + for (daily in dailies) { + val source = ownerByDay[daily.day] ?: continue + if (daily.recovery != null) { + byCell[daily.day to "recovery"] = ScoreInputProvenanceRow( + computedId, daily.day, "recovery", source, + ) + } + if (daily.strain != null) { + byCell[daily.day to "strain"] = ScoreInputProvenanceRow( + computedId, daily.day, "strain", source, + ) + } + } + for (point in metricRows) { + val source = ownerByDay[point.day] ?: continue + byCell[point.day to point.key] = ScoreInputProvenanceRow( + computedId, point.day, point.key, source, + ) + } + return byCell.values.toList() + } + + suspend fun persistDetectedSleepDetails( + repo: WhoopRepository, + computedId: String, + kept: List, + scoredNights: List, + ) { + // Only kept (not edited/dismissed) sessions receive their per-epoch motion and band-state arrays. + // Missing streams remain absent rather than being materialised as synthetic zero arrays. + if (kept.isNotEmpty()) repo.upsertSleepSessions(kept) + val keptStarts = kept.map { it.startTs }.toHashSet() + val motionByStart = HashMap>() + val sleepStateByStart = HashMap>() + for (result in scoredNights) { + for ((start, motion) in result.sessionMotionByStart) { + if (start in keptStarts) motionByStart[start] = motion + } + for ((start, states) in result.sessionSleepStateByStart) { + if (start in keptStarts) sleepStateByStart[start] = states + } + } + for ((start, motion) in motionByStart) repo.persistSessionMotion(computedId, start, motion) + for ((start, states) in sleepStateByStart) repo.persistSessionSleepState(computedId, start, states) + } +} + +/** Keep the oversized scoring orchestrator at a single, auditable transactional call site. */ +internal suspend fun WhoopRepository.replaceComputedScoreWindow( + window: IntelligencePersistence.ComputedWindow, +) = replaceComputedScoreWindow( + deviceId = window.deviceId, + from = window.from, + to = window.to, + dailyMetrics = window.dailies, + metricPoints = window.metricRows, + provenance = window.provenance, + replaceMetricKeys = listOf(DayCycleIntelligenceIntegration.ONSET_KEY), + replaceMetricSourceIds = window.markerSourceIds, +) diff --git a/android/app/src/main/java/com/noop/analytics/LocalDayWindows.kt b/android/app/src/main/java/com/noop/analytics/LocalDayWindows.kt new file mode 100644 index 0000000000..f2182d7ed5 --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/LocalDayWindows.kt @@ -0,0 +1,479 @@ +package com.noop.analytics + +import java.time.Duration +import java.time.Instant +import java.time.ZoneId + +/** + * A local calendar date as a year/month/day triple, mirroring the components of `java.time.LocalDate` + * without depending on its resolution rules. + * + * The Swift side carries its own value type because Swift has no calendar-date type free of a + * `Calendar`. This twin carries the same triple rather than a `LocalDate`, so that both sides run the + * same proleptic-Gregorian arithmetic and neither inherits a library's choice about a day boundary. + * + * Swift twin: `LocalCalendarDate`. + */ +internal data class LocalCalendarDate( + /** The proleptic Gregorian year, negative before year 1. Swift twin: `LocalCalendarDate.year`. */ + val year: Int, + /** The month, 1..12. Swift twin: `LocalCalendarDate.month`. */ + val month: Int, + /** + * The day of the month, 1..31. No validation happens here: an out-of-range day is normalised by the + * day-number conversion, the same way `java.time` would reject it and callers here never construct + * one. Swift twin: `LocalCalendarDate.day`. + */ + val day: Int, +) : Comparable { + + /** + * The date that lies `daysSinceEpoch` days after 1970-01-01. + * + * This is the counterpart of the Swift initialiser init(daysSinceEpoch:) and carries no parity + * claim, because the parity guard inventories function declarations only and has no initialiser to + * pair a claim with. + * + * The conversion is Howard Hinnant's civil_from_days, which needs no calendar object. Kotlin's + * integer division truncates toward zero as Swift's does, which is what the era shifts are written + * for. It runs inline in the delegation below and hands its three components on as one array, + * because a data class constructor may only delegate to the primary one and computing the + * components in a named helper would leave that helper with no counterpart of its own. + */ + constructor(daysSinceEpoch: Int) : this( + // `kotlin.run` by its full name: the bare form resolves to the receiver extension, and there is + // no receiver yet inside a constructor delegation. + kotlin.run { + var z = daysSinceEpoch + 719_468 + val era = (if (z >= 0) z else z - 146_096) / 146_097 + z -= era * 146_097 + val dayOfEra = z + val yearOfEra = (dayOfEra - dayOfEra / 1460 + dayOfEra / 36_524 - dayOfEra / 146_096) / 365 + val dayOfYear = dayOfEra - (365 * yearOfEra + yearOfEra / 4 - yearOfEra / 100) + val monthPrime = (5 * dayOfYear + 2) / 153 + val d = dayOfYear - (153 * monthPrime + 2) / 5 + 1 + val m = monthPrime + (if (monthPrime < 10) 3 else -9) + val y = yearOfEra + era * 400 + (if (m <= 2) 1 else 0) + intArrayOf(y, m, d) + }, + ) + + /** + * The year, month and day of a converted day number, in that order — the one hop that lets the day- + * number constructor above reach the primary one. + */ + private constructor(civil: IntArray) : this(year = civil[0], month = civil[1], day = civil[2]) + + /** + * The number of days from 1970-01-01 to this date, negative before it. + * + * Swift twin: `LocalCalendarDate.daysSinceEpoch`. + */ + val daysSinceEpoch: Int + get() { + // Howard Hinnant's days_from_civil, the exact inverse of the day-number constructor above. + val y = year - (if (month <= 2) 1 else 0) + val era = (if (y >= 0) y else y - 399) / 400 + val yearOfEra = y - era * 400 + val dayOfYear = (153 * (month + (if (month > 2) -3 else 9)) + 2) / 5 + day - 1 + val dayOfEra = yearOfEra * 365 + yearOfEra / 4 - yearOfEra / 100 + dayOfYear + return era * 146_097 + dayOfEra - 719_468 + } + + /** + * The date rendered as `YYYY-MM-DD`, which is also the day key this helper hands out. + * + * Swift twin: `LocalCalendarDate.key`. + */ + val key: String + get() { + val y = if (year < 0) "-" + pad(-year, 4) else pad(year, 4) + return "$y-${pad(month, 2)}-${pad(day, 2)}" + } + + /** + * The date `count` days after this one, negative counts moving backward. + * + * Swift twin: `LocalCalendarDate.adding(days:)`. + */ + fun adding(days: Int): LocalCalendarDate = LocalCalendarDate(daysSinceEpoch = daysSinceEpoch + days) + + /** + * Order dates the way the calendar does. + * + * Kotlin orders through `compareTo` where Swift declares the `<` operator, so the Swift `<` has no + * selector of its own to claim and this claim names the type that declares it. + * + * Swift twin: `LocalCalendarDate`. + */ + override fun compareTo(other: LocalCalendarDate): Int { + if (year != other.year) return year.compareTo(other.year) + if (month != other.month) return month.compareTo(other.month) + return day.compareTo(other.day) + } + + /** + * The zero-padding that belongs to the type rather than to an instance. + * + * Swift twin: `LocalCalendarDate`. + */ + companion object { + + /** + * Left-pad a non-negative number with zeroes, without a formatter — the Swift side does the same, + * and a locale-aware formatter is one more thing that could differ between the platforms. + * + * Swift twin: `LocalCalendarDate.pad(_:_:)`. + */ + private fun pad(value: Int, width: Int): String { + var s = value.toString() + while (s.length < width) s = "0$s" + return s + } + } +} + +/** + * One local day as a half-open window, with the UTC offset that was in effect when it began. + * + * Swift twin: `LocalDayWindow`. + */ +internal data class LocalDayWindow( + /** The date this window belongs to. Swift twin: `LocalDayWindow.date`. */ + val date: LocalCalendarDate, + /** The first instant of the date. Swift twin: `LocalDayWindow.start`. */ + val start: Instant, + /** + * The start of the next date that exists in the zone, which is where this window ends. It is not + * always `start` plus 24 hours, and where a calendar date is skipped entirely it is more than a day + * away. Swift twin: `LocalDayWindow.nextStart`. + */ + val nextStart: Instant, + /** + * The UTC offset in effect at `start`, in seconds east of UTC. + * + * It describes the window's start only. A time of day anywhere else inside the window is derived + * from the zone, never by adding this number, because a transition inside the window would make that + * wrong by an hour. Swift twin: `LocalDayWindow.utcOffsetSeconds`. + */ + val utcOffsetSeconds: Int, +) { + + /** + * How long the window lasts — 23, 24 or 25 hours in the fixture zones, and longer where the zone + * skips a calendar date. Swift returns a `TimeInterval` of seconds; the twin returns the `Duration` + * that carries the same seconds. + * + * Swift twin: `LocalDayWindow.duration`. + */ + val duration: Duration + get() = Duration.between(start, nextStart) +} + +/** + * One entry of a backward run: a date that exists in the zone, with the start resolved for it. + * + * Swift twin: `LocalDayStart`. + */ +internal data class LocalDayStart( + /** The date. Swift twin: `LocalDayStart.date`. */ + val date: LocalCalendarDate, + /** The first instant of that date in the zone. Swift twin: `LocalDayStart.start`. */ + val start: Instant, +) + +/** + * When a local calendar date begins and ends in a time zone, taken from the zone's own rules. + * + * The analysis core answers this question by adding fixed 86,400-second blocks to a midnight derived + * from a single UTC offset. That is wrong by an hour on every day boundary beyond a transition, because + * a day on which the clocks move is 23 or 25 hours long. This type is the shared primitive that answers + * it correctly; it changes no caller on its own. + * + * SO TWO ANSWERS COEXIST, and this one is not yet the shipped one. [AnalyticsEngine.dayStartUtcSeconds] + * with `AnalyticsEngine.dayString` remains what every scored day actually uses; this file is called by + * nothing but its own tests until a switch-over lands. They disagree by an hour on the far side of a + * transition, which `LocalDayWindowsTest` pins deliberately by asserting BOTH answers for + * America/New_York on 2025-10-19. Reach for the core unless you are writing that switch-over. + * + * Nothing here is inherited from the platform's date resolution: the ambiguous, skipped and + * non-existent cases are resolved by the rules stated on each operation, so that a future change in + * library behaviour is a change to this file rather than a silent change of a day boundary. That is why + * neither `ZonedDateTime` nor `ZoneRules.getTransition` appears below — the only thing asked of the + * platform is `ZoneRules.getOffset`, the zone's raw rule lookup. + * + * Both the zone and the reference instant are injectable and default to the named accessors + * `defaultTimeZone` and `defaultReferenceInstant`, so every rule below is provable without a device. + * + * Swift twin: `LocalDayWindows`. + */ +internal class LocalDayWindows( + /** The zone whose rules every operation reads. Swift twin: `LocalDayWindows.timeZone`. */ + val zone: ZoneId = defaultTimeZone, + /** + * The instant that stands for "now" — it clamps the sleep read window and nothing else. No start, + * window, key or run depends on it. Swift twin: `LocalDayWindows.referenceInstant`. + */ + val referenceInstant: Instant = defaultReferenceInstant, +) { + + /** + * The first instant that belongs to `date` in this zone, or `null` when the zone has no instant on + * that date at all. + * + * The rule, stated rather than inherited: where local midnight exists once, that is the start; where + * it exists twice, the earlier of the two; where it does not exist, the first instant that does + * exist on the date, which is the instant the clocks jumped to. Where that jump lands on a later + * date the date itself never happened, and there is no start. + * + * The rule assumes at most one offset change within two days either side of the local midnight; no + * zone in the database violates that today. + * + * The result does not depend on `referenceInstant`. + * + * Swift twin: `LocalDayWindows.start(of:)`. + */ + fun start(date: LocalCalendarDate): Instant? { + val localMidnight = date.daysSinceEpoch.toLong() * 86_400L + val offsetBefore = offsetSeconds(localMidnight - probeWindow) + val offsetAfter = offsetSeconds(localMidnight + probeWindow) + + val candidates = listOf(localMidnight - offsetBefore, localMidnight - offsetAfter) + .sorted() + .distinct() + // An instant maps to this local midnight exactly when reading it in the zone gives that wall time + // back. Ascending order means an ambiguous midnight yields the earlier instant first. + for (candidate in candidates) { + if (candidate + offsetSeconds(candidate) == localMidnight) return Instant.ofEpochSecond(candidate) + } + + // Local midnight fell in a gap. The first instant that exists on or after it is the instant the + // clocks jumped at; it belongs to this date only if it still reads as this date. + if (offsetBefore == offsetAfter) return null + val jump = transitionEpochSeconds(localMidnight, offsetBefore) + if (localDate(jump) != date) return null + return Instant.ofEpochSecond(jump) + } + + /** + * The half-open window `[start, nextStart)` of `date`, or `null` when the date does not exist. + * + * `nextStart` is the start of the next date that *exists*, so a window that precedes a skipped + * calendar date spans more than a day rather than ending at an instant that never happened. + * + * Swift twin: `LocalDayWindows.window(of:)`. + */ + fun window(date: LocalCalendarDate): LocalDayWindow? { + val dayStart = start(date) ?: return null + var next = date.adding(days = 1) + var nextStart: Instant? = null + // A zone skipping more than a couple of consecutive dates has never happened; the bound keeps a + // corrupt zone from turning this into an unbounded walk. + var probes = 0 + while (nextStart == null && probes < 8) { + nextStart = start(next) + if (nextStart == null) next = next.adding(days = 1) + probes += 1 + } + val end = nextStart ?: return null + return LocalDayWindow( + date = date, + start = dayStart, + nextStart = end, + utcOffsetSeconds = offsetSeconds(dayStart), + ) + } + + /** + * The start of the local day that contains `instant`, as an instant and without formatting a date on + * the way. + * + * Swift twin: `LocalDayWindows.startOfDay(containing:)`. + */ + fun startOfDay(instant: Instant): Instant { + val date = localDate(instant) + // `instant` lies on `date` by construction, so the date exists and has a start. The fallback is + // unreachable and exists only to keep the operation total. + return start(date) ?: instant + } + + /** + * The `YYYY-MM-DD` key of the local day that contains `instant`. + * + * The key and the window agree by construction: the key names the date whose window contains the + * instant, because both are derived from the same zone lookup. + * + * Swift twin: `LocalDayWindows.dayKey(for:)`. + */ + fun dayKey(instant: Instant): String = localDate(instant).key + + /** + * The local calendar date that contains `instant` in this zone. + * + * Swift twin: `LocalDayWindows.localDate(of:)`. + */ + fun localDate(instant: Instant): LocalCalendarDate = localDate(instant.epochSecond) + + /** + * Up to `count` consecutive local calendar dates ending at `date`, oldest first, each with its own + * resolved start. + * + * A date the zone skipped is omitted rather than replaced, so the run can be shorter than requested; + * if `date` itself does not exist, the newest entry is the latest existing date before it. + * + * Swift twin: `LocalDayWindows.backwardRun(endingAt:count:)`. + */ + fun backwardRun(endingAt: LocalCalendarDate, count: Int): List { + if (count <= 0) return emptyList() + val endDay = endingAt.daysSinceEpoch + val run = ArrayList(count) + for (back in count - 1 downTo 0) { + val day = LocalCalendarDate(daysSinceEpoch = endDay - back) + val start = start(day) + if (start != null) run.add(LocalDayStart(date = day, start = start)) + } + return run + } + + /** + * The end of the sleep read window of `date`: the earlier of that date's next start and the + * reference instant, so the current day's window never reaches into the future. `null` when the date + * does not exist. + * + * For a date that begins after the reference instant the end precedes the start — an empty, inverted + * window — by the D7 contract; a caller wanting a non-empty window must not ask for a future date. + * + * Swift twin: `LocalDayWindows.sleepReadWindowEnd(of:)`. + */ + fun sleepReadWindowEnd(date: LocalCalendarDate): Instant? { + val window = window(date) ?: return null + return minOf(window.nextStart, referenceInstant) + } + + /** + * The UTC offset in effect at `instant`, in seconds east of UTC. + * + * Swift twin: `LocalDayWindows.offsetSeconds(at:)`. + */ + fun offsetSeconds(instant: Instant): Int = zone.rules.getOffset(instant).totalSeconds + + /** + * The UTC offset in effect at a whole-second instant. + * + * Swift twin: `LocalDayWindows.offsetSeconds(atEpochSeconds:)`. + */ + private fun offsetSeconds(epochSeconds: Long): Int = + zone.rules.getOffset(Instant.ofEpochSecond(epochSeconds)).totalSeconds + + /** + * The local calendar date of a whole-second instant. + * + * Swift twin: `LocalDayWindows.localDate(ofEpochSeconds:)`. + */ + private fun localDate(epochSeconds: Long): LocalCalendarDate { + val localSeconds = epochSeconds + offsetSeconds(epochSeconds) + var days = localSeconds / 86_400L + if (localSeconds < 0 && localSeconds % 86_400L != 0L) days -= 1 + return LocalCalendarDate(daysSinceEpoch = days.toInt()) + } + + /** + * The instant at which the zone's offset changes near `localMidnight`, found by bisecting the probe + * window between the offset before and the offset after. + * + * Swift twin: `LocalDayWindows.transitionEpochSeconds(around:offsetBefore:)`. + */ + private fun transitionEpochSeconds(localMidnight: Long, offsetBefore: Int): Long { + var low = localMidnight - probeWindow + var high = localMidnight + probeWindow + while (high - low > 1) { + val mid = low + (high - low) / 2 + if (offsetSeconds(mid) == offsetBefore) low = mid else high = mid + } + return high + } + + /** + * The named defaults and the version reporting, which belong to the type rather than to an instance. + * + * Swift twin: `LocalDayWindows`. + */ + companion object { + + /** + * The zone used when none is given: the system default zone id, which follows a change of device + * zone during the process's life. The Swift twin's default is the auto-updating current zone, + * which means the same thing. + * + * The zone is bound when the helper is constructed; a caller that must follow a device-zone + * change during the process's life constructs a new helper. + * + * Swift twin: `LocalDayWindows.defaultTimeZone`. + */ + val defaultTimeZone: ZoneId + get() = ZoneId.systemDefault() + + /** + * The reference instant used when none is given: the current `Instant`. The Swift twin's default + * is the current `Date`. + * + * Swift twin: `LocalDayWindows.defaultReferenceInstant`. + */ + val defaultReferenceInstant: Instant + get() = Instant.now() + + /** + * The time-zone database version this platform can observe about itself, or `null` where it + * cannot. + * + * The JVM reports the database its own `java.time` rules were built from, which is a different + * fact from the one the oracle records about the producing Swift toolchain. Where that fact + * cannot be had the answer is `null` rather than a faked value, which is the same choice the + * Swift side makes on Linux. `java.time.zone.ZoneRulesProvider` is not on the SDK compile + * classpath, so it is read reflectively rather than called directly; what a device returns is + * unverified here and belongs to A2's device counter-check. Where the read cannot be made the + * answer is the `null` branch, which the note renders as the D6 cannot-observe wording; on the + * JVM unit-test runner the read succeeds and the note names the version. + * + * Swift twin: `LocalDayWindows.observedTimeZoneDataVersion`. + */ + val observedTimeZoneDataVersion: String? + get() = try { + val versions = Class.forName("java.time.zone.ZoneRulesProvider") + .getMethod("getVersions", String::class.java) + .invoke(null, ZoneId.systemDefault().id) as java.util.NavigableMap<*, *> + if (versions.isEmpty()) null else versions.lastKey() as? String + } catch (missing: ReflectiveOperationException) { + null + } catch (unsupported: RuntimeException) { + // `ZoneRulesException` for a zone the provider does not know, wrapped or direct. + null + } + + /** + * The note a platform attaches when a fixture value moves: it names the version recorded in the + * oracle and the version this platform observes at run time. + * + * A version difference is not itself a failure — the fixtures are past and stable, so a routine + * toolchain bump must not redden unrelated work. The note exists to make a *value* difference + * explicable. It is a pure function so that its content can be asserted; where a platform cannot + * observe its own version, the note says so in place of a version rather than omitting it. + * + * Swift twin: `LocalDayWindows.timeZoneDataVersionNote(recorded:observed:)`. + */ + fun timeZoneDataVersionNote(recorded: String, observed: String?): String { + val observedText = observed?.let { "observes $it" } + ?: "cannot observe its own time-zone database version" + return "time-zone database version: the oracle records $recorded; this platform $observedText." + } + + /** + * The number of seconds either side of a local midnight that is probed for the offsets in effect + * around it. Two days is far more than any transition needs and still far less than the gap + * between two transitions anywhere in the database. + * + * Swift twin: `LocalDayWindows.probeWindow`. + */ + private const val probeWindow: Long = 2L * 86_400L + } +} diff --git a/android/app/src/main/java/com/noop/analytics/PhysiologicalStepCycleEngine.kt b/android/app/src/main/java/com/noop/analytics/PhysiologicalStepCycleEngine.kt new file mode 100644 index 0000000000..82c8fc3066 --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/PhysiologicalStepCycleEngine.kt @@ -0,0 +1,359 @@ +package com.noop.analytics + +import com.noop.data.MetricSeriesRow +import com.noop.data.SleepSession +import com.noop.data.WhoopRepository +import kotlin.math.roundToLong + +/** + * Sleep-onset-to-sleep-onset step materialisation. Kept outside [IntelligenceEngine]'s already-large + * scoring method so the JVM method stays below its 64 KiB bytecode limit and the feature has one boundary. + * The cache retains summaries only; raw step pages are released immediately after each segment. + */ +internal object PhysiologicalStepCycleEngine { + data class Result( + val cycleStepsByWakeDay: Map, + val cycleStrainByWakeDay: Map, + val cycleCaloriesByWakeDay: Map, + val cycleWorkoutCountByWakeDay: Map, + val boundaryOnsetByWakeDay: Map, + val firstCycleWakeDay: String?, + val recoveredOwnerMarkerRows: List, + ) + + private data class CachedCycleSteps( + val key: String, + val count: SleepAwareStepCounter.Count, + val pages: Int, + val samples: Int, + val evaluated: Boolean, + ) + + /** Process-local and serialized by IntelligenceEngine's analyze gate. */ + private val cache = HashMap() + + suspend fun compute( + scoredNights: List, + editedRows: List, + resolvedScoreOwnerByDay: Map, + candidatePriorities: List>, + stepWitnessByDay: Map, + repo: WhoopRepository, + tzOffsetSeconds: Long, + habitualMidsleepSec: Long?, + windowStart: Long, + nowSeconds: Long, + stepTicksPerStep: Double, + stepsTraceSink: ((String) -> Unit)?, + dayCycleMode: DayCycleMode, + profile: UserProfile, + maxHROverride: Double?, + effortMethod: StrainScorer.Method, + ): Result { + if (dayCycleMode == DayCycleMode.MIDNIGHT) { + return Result(emptyMap(), emptyMap(), emptyMap(), emptyMap(), emptyMap(), null, emptyList()) + } + val editedRowsByDay = editedRows.groupBy { + AnalyticsEngine.dayString(it.endTs, tzOffsetSeconds) + } + val boundaries = ArrayList() + val dayBySleepId = LinkedHashMap() + val ownerBySleepId = LinkedHashMap() + val sleepContext = ArrayList() + val recovered = ArrayList() + + for (res in scoredNights) { + val dayEdits = editedRowsByDay[res.daily.day].orEmpty() + val editsByDetectedStart = dayEdits.associateBy { it.startTs } + val detectedStarts = res.sleepSessions.mapTo(HashSet()) { it.start } + val blocks = buildList { + for (session in res.sleepSessions) { + val edit = editsByDetectedStart[session.start] + sleepContext += SleepAwareStepCounter.withBounds( + session, + start = edit?.effectiveStartTs ?: session.start, + end = edit?.endTs ?: session.end, + ) + add( + PhysiologicalSteps.SleepBlock( + onset = session.start, + end = edit?.endTs ?: session.end, + id = session.start.toString(), + editedOnset = edit?.effectiveStartTs, + ), + ) + } + for (edit in dayEdits) { + if (edit.startTs !in detectedStarts) { + add( + PhysiologicalSteps.SleepBlock( + onset = edit.startTs, + end = edit.endTs, + id = "manual:${edit.startTs}", + editedOnset = edit.startTsAdjusted, + kind = PhysiologicalSteps.SleepKind.NAP, + ), + ) + sleepContext += DetectedSleep( + start = edit.effectiveStartTs, + end = edit.endTs, + efficiency = 0.0, + stages = emptyList(), + restingHR = null, + avgHRV = null, + ) + } + } + } + val classified = PhysiologicalSteps.classifyForCycle( + blocks, tzOffsetSeconds, habitualMidsleepSec, + ) + PhysiologicalSteps.mainSleepOnset( + classified, tzOffsetSeconds, habitualMidsleepSec, + )?.let { onset -> + val winner = classified.firstOrNull { + it.kind == PhysiologicalSteps.SleepKind.MAIN_SLEEP && it.effectiveOnset == onset + } ?: return@let + boundaries += PhysiologicalSteps.CycleBoundary(winner.id, onset) + dayBySleepId[winner.id] = res.daily.day + resolvedScoreOwnerByDay[res.daily.day]?.let { ownerBySleepId[winner.id] = it } + } + } + + if (candidatePriorities.isNotEmpty()) { + val markerFromDay = AnalyticsEngine.dayString(windowStart, tzOffsetSeconds) + val markerToDay = AnalyticsEngine.dayString(nowSeconds, tzOffsetSeconds) + val sessionsByOwner = LinkedHashMap>() + val markersByOwner = LinkedHashMap>() + for (owner in candidatePriorities.map { it.first }.distinct()) { + val ownerComputedId = repo.computedDeviceId(owner) + sessionsByOwner[owner] = repo.sleepSessionsForDevice( + ownerComputedId, windowStart, nowSeconds, limit = 4_000, + ) + markersByOwner[owner] = repo.metricSeries( + ownerComputedId, + DayCycleIntelligenceIntegration.ONSET_KEY, + markerFromDay, + markerToDay, + ).mapNotNull { row -> + val onset = row.value.toLong() + if (row.value == onset.toDouble()) row.day to onset else null + }.toMap() + } + for (item in DayCycleIntelligenceIntegration.persistedBoundaries( + candidates = candidatePriorities, + sessionsByOwner = sessionsByOwner, + markerOnsetByOwnerAndDay = markersByOwner, + currentBoundaryWakeDays = dayBySleepId.values.toHashSet(), + tzOffsetSeconds = tzOffsetSeconds, + habitualMidsleepSec = habitualMidsleepSec, + )) { + recovered += item + boundaries += item.boundary + dayBySleepId[item.boundary.sleepId] = item.wakeDay + ownerBySleepId[item.boundary.sleepId] = item.owner + sleepContext += item.sleepContext + } + } + + // Resolve the open tail through the shared day-cycle policy. Step storage proves strap coverage, + // not wakefulness: a missed sleep detection has the same endpoints. Until a dedicated, validated + // awake signal exists, unknown therefore takes the safe midnight fallback. + boundaries.maxByOrNull { it.onset }?.let { latest -> + val wakeDay = dayBySleepId[latest.sleepId] + val owner = ownerBySleepId[latest.sleepId] + if (wakeDay != null && owner != null) { + val active = DayCycleResolver.activeWindow( + mode = dayCycleMode, + latestSleep = DayCycleWindow( + id = latest.sleepId, + startInclusive = latest.onset, + endExclusive = nowSeconds, + displayDay = wakeDay, + source = DayCycleWindow.Source.DETECTED_SLEEP, + ), + now = nowSeconds, + tzOffsetSeconds = tzOffsetSeconds, + ) + if (active.source == DayCycleWindow.Source.SYNTHETIC_MIDNIGHT) { + val synthetic = PhysiologicalSteps.CycleBoundary(active.id, active.startInclusive) + boundaries += synthetic + dayBySleepId[synthetic.sleepId] = active.displayDay + ownerBySleepId[synthetic.sleepId] = owner + } + } + } + + val stepsByWakeDay = HashMap() + val strainByWakeDay = HashMap() + val caloriesByWakeDay = HashMap() + val workoutCountByWakeDay = HashMap() + val windows = PhysiologicalSteps.cycleWindows(boundaries, nowSeconds) + val allDetectedSleep = sleepContext.distinctBy { it.start to it.end } + cache.keys.retainAll(windows.mapTo(HashSet()) { it.sleepId }) + for (window in windows) { + val wakeDay = dayBySleepId[window.sleepId] ?: continue + val fallbackOwner = ownerBySleepId[window.sleepId] ?: continue + // DAO ranges are inclusive. Keep adjacent physiological cycles disjoint. + val cycleHr = repo.hrSamplesUnion( + fallbackOwner, window.onset, window.endExclusive - 1L, 200_000, + ) + val restingHr = scoredNights.firstOrNull { it.daily.day == wakeDay }?.daily?.restingHr?.toDouble() + ?: StrainScorer.defaultRestingHR + val effectiveMaxHr = maxHROverride + ?: profile.age.takeIf { it > 0 }?.let { StrainScorer.tanakaHRmax(it.toDouble()) } + StrainScorer.strain(cycleHr, effectiveMaxHr, restingHr, effortMethod, profile.sex) + ?.let { strainByWakeDay[wakeDay] = it } + if (cycleHr.isNotEmpty()) { + caloriesByWakeDay[wakeDay] = Calories.estimateDayCalories( + cycleHr, profile, effectiveMaxHr, restingHr, + ) + } + // Count the persisted all-source workout union, not only analyzer-detected bouts. Repository + // reads are inclusive, while ownership is [onset, nextOnset). + val workoutEndInclusive = window.endExclusive - 1L + val cycleWorkouts = ( + repo.workoutsUnion(fallbackOwner, window.onset, workoutEndInclusive, 100_000) + + repo.detectedWorkoutsUnion(fallbackOwner, window.onset, workoutEndInclusive, 100_000) + + listOf("apple-health", "health-connect", "lifting", "activity-file").flatMap { source -> + repo.workouts(source, window.onset, workoutEndInclusive, 100_000) + } + ).distinctBy { Triple(it.startTs, it.endTs, it.sport) } + val freshDetectedKeys = scoredNights.asSequence().flatMap { it.workouts.asSequence() } + .filter { it.start >= window.onset && it.start < window.endExclusive } + .filter { detected -> + cycleWorkouts.none { persisted -> + detected.start < persisted.endTs && persisted.startTs < detected.end + } + } + .map { it.start to it.end } + .toList() + workoutCountByWakeDay[wakeDay] = ( + cycleWorkouts.map { it.startTs to it.endTs } + freshDetectedKeys + ).distinct().size + val priorities = LinkedHashMap() + for ((owner, priority) in candidatePriorities) priorities[owner] = priority + priorities.putIfAbsent(fallbackOwner, priorities.values.minOrNull() ?: 0) + val coverage = priorities.mapNotNull { (owner, priority) -> + val span = repo.stepTimestampCoverage(owner, window.onset, window.endExclusive) + val first = span.firstTs + val last = span.lastTs + if (first == null || last == null) null else PhysiologicalSteps.OwnerCoverage( + owner, first, (last + 1L).coerceAtMost(window.endExclusive), priority, + ) + } + val segments = PhysiologicalSteps.ownerSegmentsFromCoverage(window, coverage, fallbackOwner) + if (segments.isEmpty()) continue + val active = window.endExclusive == nowSeconds + val ownerIdentity = segments.mapIndexed { index, segment -> + val identityEnd = if (active && index == segments.lastIndex) 0L else segment.endExclusive + "${segment.owner}:${segment.onset}-$identityEnd" + }.joinToString(",") + val stepRevision = segments.joinToString("|") { segment -> + "${segment.owner}=" + repo.stepDataRevisionSignature( + segment.owner, segment.onset, segment.endExclusive, + ) + } + val contextSignature = SleepAwareStepCounter.contextSignature( + allDetectedSleep, window.onset, window.endExclusive, + ) + val firstDay = AnalyticsEngine.dayString(window.onset, tzOffsetSeconds) + val lastDay = AnalyticsEngine.dayString( + (window.endExclusive - 1L).coerceAtLeast(window.onset), tzOffsetSeconds, + ) + val witnesses = stepWitnessByDay.entries.asSequence() + .filter { it.key >= firstDay && it.key <= lastDay } + .map { it.key to it.value } + .toList() + val key = DayCycleIntelligenceIntegration.cacheKey( + owner = ownerIdentity, + sleepId = window.sleepId, + onset = window.onset, + end = if (active) 0L else window.endExclusive, + stepRevision = stepRevision, + sleepContextSignature = contextSignature, + contributingDayKeys = witnesses, + ) + var cached = cache[window.sleepId] + if (DayCycleIntelligenceIntegration.shouldRecount(cached?.key, key)) { + var pages = 0 + var samples = 0 + var evaluated = false + var combinedCount = SleepAwareStepCounter.Count.EMPTY + for ((segmentIndex, segment) in segments.withIndex()) { + val hasClasses = repo.hasStepActivityClasses( + segment.owner, segment.onset, segment.endExclusive, + ) + val accumulator = SleepAwareStepCounter.Accumulator(allDetectedSleep, hasClasses) + var segmentSamples = 0 + if (PhysiologicalSteps.shouldReadCounterPredecessor(segmentIndex)) { + repo.stepSampleBefore(segment.owner, segment.onset)?.let { + accumulator.acceptPage(listOf(it)) + samples++ + segmentSamples++ + } + } + var cursor = segment.onset - 1L + while (cursor < segment.endExclusive) { + val page = repo.stepSamplesPage( + segment.owner, + cursor, + segment.endExclusive, + IntelligenceEngine.PHYSIOLOGICAL_STEP_PAGE_SIZE, + ) + if (page.isEmpty()) break + accumulator.acceptPage(page) + pages++ + samples += page.size + segmentSamples += page.size + val next = page.last().ts + if (next <= cursor) break + cursor = next + if (page.size < IntelligenceEngine.PHYSIOLOGICAL_STEP_PAGE_SIZE) break + } + if (segmentSamples >= 2) evaluated = true + combinedCount += accumulator.finish() + } + cached = CachedCycleSteps(key, combinedCount, pages, samples, evaluated) + cache[window.sleepId] = cached + } + val result = cached ?: continue + if (result.evaluated) { + val scaled = (result.count.totalTicks.toDouble() / maxOf(stepTicksPerStep, 0.5)) + .roundToLong().coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + stepsByWakeDay[wakeDay] = scaled + stepsTraceSink?.invoke( + "stepsCycle wakeDay=$wakeDay status=${if (active) "active" else "closed"} " + + "onsetTs=${window.onset} endTs=${window.endExclusive} owner=$ownerIdentity " + + "pages=${result.pages} samples=${result.samples} " + + "totalTicks=${result.count.totalTicks} " + + "outside=${result.count.acceptedOutsideSleepTicks} " + + "awakeGap=${result.count.acceptedAwakeGapTicks} " + + "sleepBout=${result.count.acceptedSleepBoutTicks} " + + "rejectedIsolatedSleep=${result.count.rejectedIsolatedSleepTicks} " + + "rejectedClass=${result.count.rejectedActivityClassTicks} " + + "rejectedImplausible=${result.count.rejectedImplausibleTicks} " + + "gravitySamples=${result.count.gravitySamplesAvailable} " + + "auxSamples=${result.count.auxSamplesAvailable} " + + "ticksPerStep=$stepTicksPerStep " + + "scaledSteps=$scaled", + ) + } + } + + return Result( + cycleStepsByWakeDay = stepsByWakeDay, + cycleStrainByWakeDay = strainByWakeDay, + cycleCaloriesByWakeDay = caloriesByWakeDay, + cycleWorkoutCountByWakeDay = workoutCountByWakeDay, + boundaryOnsetByWakeDay = boundaries.mapNotNull { boundary -> + dayBySleepId[boundary.sleepId]?.let { it to boundary.onset } + }.toMap(), + firstCycleWakeDay = dayBySleepId.values.minOrNull(), + recoveredOwnerMarkerRows = DayCycleIntelligenceIntegration.recoveredMarkerRows( + recovered, + repo::computedDeviceId, + ), + ) + } +} diff --git a/android/app/src/main/java/com/noop/analytics/PhysiologicalSteps.kt b/android/app/src/main/java/com/noop/analytics/PhysiologicalSteps.kt new file mode 100644 index 0000000000..e62c2ded73 --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/PhysiologicalSteps.kt @@ -0,0 +1,208 @@ +package com.noop.analytics + +/** + * WHOOP-style step-day boundaries. A physiological step day starts at the onset of the main sleep + * and ends at the next main-sleep onset; the delta is still attributed to the later counter sample. + * We deliberately do not mask the sleep span: a real walk during a long night-time wake remains a + * walk, while the shared activity-class and tick-rate gates continue to reject non-locomotion. + */ +object PhysiologicalSteps { + + enum class SleepKind { MAIN_SLEEP, NAP, UNCLASSIFIED } + + data class SleepBlock( + val onset: Long, + val end: Long, + /** Stable identity lets a user-edited onset move an existing boundary instead of adding one. */ + val id: String = onset.toString(), + val editedOnset: Long? = null, + val kind: SleepKind = SleepKind.UNCLASSIFIED, + ) { + val effectiveOnset: Long get() = editedOnset ?: onset + } + + /** The one open physiological cycle. Absence of a newly detected night never creates a calendar cycle. */ + data class CycleBoundary(val sleepId: String, val onset: Long) + + data class CycleWindow(val sleepId: String, val onset: Long, val endExclusive: Long) + + data class OwnerSegment(val owner: String, val onset: Long, val endExclusive: Long) + data class OwnerCoverage( + val owner: String, + val onset: Long, + val endExclusive: Long, + val priority: Int, + ) + + fun shouldReadCounterPredecessor(segmentIndex: Int): Boolean = segmentIndex == 0 + + private const val MIN_MAIN_SLEEP_SECONDS = 3 * 3_600L + + + /** + * Apply the same user-visible main-vs-nap shape used by the sleep surfaces: only the canonical overnight + * group can be MAIN, it must total at least three hours, and every other/twinless-explicit block is NAP. + */ + fun classifyForCycle( + blocks: List, + tzOffsetSeconds: Long, + habitualMidsleepSec: Long?, + ): List { + if (blocks.isEmpty()) return emptyList() + val explicitMain = blocks.indices.filter { blocks[it].kind == SleepKind.MAIN_SLEEP } + val selectable = blocks.indices.filter { blocks[it].kind != SleepKind.NAP } + val selectedOriginalIndices = if (explicitMain.isNotEmpty()) { + explicitMain + } else { + val selectableNightBlocks = selectable.map { + val b = blocks[it] + SleepStageTotals.NightBlock(b.effectiveOnset, b.end) + } + // Eliminate nap-shaped GROUPS before choosing a winner. Otherwise a six-hour afternoon nap can + // win the generic duration scorer, fail the daytime guard, and hide a valid shorter night. + val eligible = SleepStageTotals.bridgedNightGroups(selectableNightBlocks, tzOffsetSeconds) + .filter { group -> + val total = group.indices.sumOf { i -> selectableNightBlocks[i].durationS.coerceAtLeast(0L) } + val onset = group.indices.minOfOrNull { selectableNightBlocks[it].start } + total >= MIN_MAIN_SLEEP_SECONDS && onset != null && + SleepStageTotals.isOvernightOnset(onset, tzOffsetSeconds) + } + .flatMap { it.indices } + .distinct() + SleepStageTotals.mainNightGroupIndices( + eligible.map { selectableIndex -> + val original = selectable[selectableIndex] + val b = blocks[original] + SleepStageTotals.NightBlock(b.effectiveOnset, b.end) + }, + tzOffsetSeconds, + habitualMidsleepSec, + ).orEmpty().map { selectedIndex -> + selectable[eligible[selectedIndex]] + } + }.toHashSet() + return blocks.mapIndexed { index, block -> + block.copy(kind = if (index in selectedOriginalIndices) SleepKind.MAIN_SLEEP else SleepKind.NAP) + } + } + + /** Use the exact same main-night/group selector as the Sleep screen and daily sleep aggregate. */ + fun mainSleepOnset( + blocks: List, + tzOffsetSeconds: Long, + habitualMidsleepSec: Long?, + ): Long? { + val eligible = classifyForCycle(blocks, tzOffsetSeconds, habitualMidsleepSec) + .filter { it.kind == SleepKind.MAIN_SLEEP } + val indices = SleepStageTotals.mainNightGroupIndices( + eligible.map { SleepStageTotals.NightBlock(it.effectiveOnset, it.end) }, + tzOffsetSeconds, + habitualMidsleepSec, + ) ?: return null + return indices.minOfOrNull { eligible[it].effectiveOnset } + } + + /** + * Build historical sleep-to-sleep windows without inventing rows at midnight. The final observed + * boundary alone owns the open tail to [now], including days on which no new sleep was detected. + */ + fun cycleWindows(boundaries: List, now: Long): List { + val ordered = boundaries.asSequence() + .filter { it.onset <= now } + .distinctBy { it.sleepId } + .sortedBy { it.onset } + .toList() + return ordered.mapIndexedNotNull { index, boundary -> + val end = ordered.getOrNull(index + 1)?.onset ?: now + if (end <= boundary.onset) null else CycleWindow(boundary.sleepId, boundary.onset, end) + } + } + + /** Split at local midnights and merge adjacent same-owner slices; counters never cross device seams. */ + fun ownerSegments( + window: CycleWindow, + ownerByDay: Map, + tzOffsetSeconds: Long, + fallbackOwner: String, + ): List { + if (window.endExclusive <= window.onset) return emptyList() + val out = ArrayList() + var cursor = window.onset + var lastOwner = fallbackOwner + while (cursor < window.endExclusive) { + val day = AnalyticsEngine.dayString(cursor, tzOffsetSeconds) + val local = cursor + tzOffsetSeconds + val dayNumber = Math.floorDiv(local, SleepStageTotals.SECONDS_PER_DAY) + val dayStart = dayNumber * SleepStageTotals.SECONDS_PER_DAY - tzOffsetSeconds + // The sleep-owner owns the partial onset day. Calendar owner changes take effect only at a + // midnight seam, never retroactively before the boundary sleep began. + val owner = if (cursor == window.onset && cursor > dayStart) { + fallbackOwner + } else { + ownerByDay[day] ?: lastOwner + } + val nextMidnight = (dayNumber + 1) * SleepStageTotals.SECONDS_PER_DAY - tzOffsetSeconds + val end = minOf(window.endExclusive, nextMidnight) + val previous = out.lastOrNull() + if (previous != null && previous.owner == owner && previous.endExclusive == cursor) { + out[out.lastIndex] = previous.copy(endExclusive = end) + } else { + out += OwnerSegment(owner, cursor, end) + } + lastOwner = owner + cursor = end + } + return out + } + + /** + * Timestamp coverage wins at every seam; priority resolves overlap, so two worn straps never add. + * Each coverage is a continuous first..last ownership span by design: an internal sample/radio gap + * creates no seam and therefore cannot hand the interval to a lower-priority concurrently worn strap. + */ + fun ownerSegmentsFromCoverage( + window: CycleWindow, + coverage: List, + fallbackOwner: String, + ): List { + if (window.endExclusive <= window.onset) return emptyList() + val clipped = coverage.mapNotNull { + val start = maxOf(window.onset, it.onset) + val end = minOf(window.endExclusive, it.endExclusive) + if (end <= start) null else it.copy(onset = start, endExclusive = end) + } + val seams = buildSet { + add(window.onset) + add(window.endExclusive) + clipped.forEach { add(it.onset); add(it.endExclusive) } + }.sorted() + val out = ArrayList() + var lastOwner = fallbackOwner + for (i in 0 until seams.lastIndex) { + val start = seams[i] + val end = seams[i + 1] + val owner = clipped.asSequence() + .filter { start >= it.onset && start < it.endExclusive } + .minWithOrNull(compareBy { it.priority }.thenBy { it.owner }) + ?.owner ?: lastOwner + val previous = out.lastOrNull() + if (previous != null && previous.owner == owner && previous.endExclusive == start) { + out[out.lastIndex] = previous.copy(endExclusive = end) + } else { + out += OwnerSegment(owner, start, end) + } + lastOwner = owner + } + return out + } + + /** Resolve the next observed onset, or [now] for the currently open physiological cycle. */ + fun cycleEnd(day: String, onsetByDay: Map, now: Long): Long { + val onset = onsetByDay[day] ?: return now + return onsetByDay.values.asSequence() + .filter { it > onset && it <= now } + .minOrNull() + ?: now + } + +} diff --git a/android/app/src/main/java/com/noop/analytics/PreSleepHeartRateFeedback.kt b/android/app/src/main/java/com/noop/analytics/PreSleepHeartRateFeedback.kt new file mode 100644 index 0000000000..d120d3fdb2 --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/PreSleepHeartRateFeedback.kt @@ -0,0 +1,280 @@ +package com.noop.analytics + +import com.noop.data.JournalEntry +import com.noop.protocol.HrSample +import java.time.LocalDate +import java.time.temporal.ChronoUnit + +/** + * An opt-in, descriptive pre-sleep heart-rate reading for the following morning. + * + * Kotlin twin of `StrandAnalytics.PreSleepHeartRateFeedback` (#1784). The caller supplies a + * timestamp-coalesced HR timeline: the repository's existing HR reads already coalesce measured and + * PPG-derived samples, with measured data taking precedence. This object selects the longest supplied + * sleep session, reads the declared half-open window immediately before it, and compares that + * observation with a personal rolling baseline. It neither diagnoses nor recommends an action. Journal + * answers are returned only as facts from the same day; one night cannot establish an association, so + * they never alter the reading or become an insight. + * + * PARITY, and the two places this deliberately differs from the Swift original: + * + * - Apple's `SleepSession` is this side's [DetectedSleep] — same shape, the naming divergence that + * already exists between the two analytics layers. + * - Apple carries session timestamps as `Int`; [DetectedSleep] carries `Long`, so the window + * arithmetic here is `Long` and sample timestamps widen at the comparison. The overflow guard is + * therefore `Math.subtractExact` rather than `subtractingReportingOverflow`, which is the same + * contract — fail closed, never trap — expressed in the idiom each language actually has. + * + * Everything that produces a NUMBER is byte-identical by construction: the same [MetricCfg], the same + * [Baselines.rollingMeanSD], the same [Baselines.computeStatus], and the same HR plausibility range. + */ +object PreSleepHeartRateFeedback { + + /** + * Baseline configuration deliberately shares the existing HR plausibility range while keeping a + * small floor spread for transparent personal comparison. `rollingMeanSD` is the existing auditable + * baseline primitive; this is not a new scoring model. + */ + val baselineCfg = MetricCfg( + minVal = SleepHeartRateContrast.VALID_MIN_BPM, + maxVal = SleepHeartRateContrast.VALID_MAX_BPM, + floorSpread = 2.0, + halfLifeB = 14.0, + halfLifeS = 21.0, + ) + const val DEFAULT_PRE_SLEEP_WINDOW_SECONDS: Int = 30 * 60 + const val DEFAULT_MINIMUM_VALID_SAMPLES: Int = 10 + val minimumBaselineNights: Int = Baselines.minNightsSeed + + /** + * A prior eligible pre-sleep observation. Callers persist/assemble history; this pure slice does + * not write, schedule a prompt, or penalize a missing night. + */ + data class HistoricalReading(val day: String, val meanBpm: Double) + + /** + * Why a morning reading is or is not available. Every non-eligible state is recoverable on a + * later, sufficiently covered night; none creates a streak, goal, or obligation. + */ + sealed class Eligibility { + object Disabled : Eligibility() + object InvalidDay : Eligibility() + object InvalidWindow : Eligibility() + object MissingPrimarySleep : Eligibility() + data class InsufficientPreSleepSamples(val valid: Int, val required: Int) : Eligibility() + data class InsufficientBaseline(val validNights: Int, val required: Int) : Eligibility() + data class StaleBaseline(val daysSinceUpdate: Int) : Eligibility() + object Eligible : Eligibility() + } + + /** + * The observed, un-imputed pre-sleep data. Completeness is stated as a sample count because raw + * HR cadence can vary; this slice does not pretend a count is a percentage of time covered. + */ + data class Observation( + val primarySleepStartTs: Long, + val primarySleepEndTs: Long, + val windowStartTs: Long, + val windowEndTs: Long, + val meanBpm: Double, + val validSamples: Int, + val totalTimestampSamples: Int, + ) + + /** A personal comparison only, never a population norm or a health classification. */ + data class Comparison( + val baselineBpm: Double, + val deltaBpm: Double, + val baselineNights: Int, + val baselineStatus: BaselineStatus, + ) + + /** Explicit limits that a presentation layer must keep visible rather than turning into certainty. */ + sealed class Uncertainty { + /** The first eligible personal baseline is usable but not yet trusted by [Baselines]. */ + object ProvisionalBaseline : Uncertainty() + /** There are not yet enough valid historical nights to make any personal comparison. */ + object NoPersonalComparison : Uncertainty() + /** A mature personal baseline has not received a plausible value recently enough to compare. */ + data class StaleBaseline(val daysSinceUpdate: Int) : Uncertainty() + } + + /** This slice intentionally makes no causal inference from a single observation or its journal facts. */ + enum class Inference { NOT_ESTABLISHED } + + /** This slice intentionally cannot support a behavior, treatment, or other recommendation. */ + enum class Recommendation { UNSUPPORTED } + + /** + * A projection of a same-day [JournalEntry]. It preserves what was logged without exposing notes or + * claiming that the entry explains this reading. + */ + data class JournalFact( + val day: String, + val question: String, + val answeredYes: Boolean, + val numericValue: Double?, + ) + + data class Feedback( + val eligibility: Eligibility, + val observation: Observation?, + val comparison: Comparison?, + val uncertainty: List, + val inference: Inference, + val recommendation: Recommendation, + /** Same-day journal facts only. They are deliberately not treated as a causal insight. */ + val journalContext: List, + ) + + /** + * Produce the next-morning reading from existing timestamped HR and sleep primitives. + * + * [hr] is expected to be the repository's measured/PPG-coalesced timeline. To remain safe for other + * callers, duplicate timestamps are also ignored after the first element, preserving the caller's + * precedence order. Both window bounds are transparent and half-open: + * `[sleep.start - window, sleep.start)`. + */ + fun evaluate( + enabled: Boolean, + sessions: List, + hr: List, + history: List, + journalEntries: List, + day: String, + minimumValidSamples: Int = DEFAULT_MINIMUM_VALID_SAMPLES, + preSleepWindowSeconds: Int = DEFAULT_PRE_SLEEP_WINDOW_SECONDS, + ): Feedback { + fun closed(e: Eligibility, obs: Observation? = null, unc: List = emptyList(), + ctx: List = emptyList()) = + Feedback(e, obs, null, unc, Inference.NOT_ESTABLISHED, Recommendation.UNSUPPORTED, ctx) + + if (!enabled) return closed(Eligibility.Disabled) + val evaluationDate = canonicalDay(day) ?: return closed(Eligibility.InvalidDay) + if (minimumValidSamples <= 0 || preSleepWindowSeconds <= 0) return closed(Eligibility.InvalidWindow) + + val sessionsWithDuration = sessions.mapNotNull { session -> + val duration = subtractOrNull(session.end, session.start) + if (duration != null && duration > 0L) session to duration else null + } + val primary = sessionsWithDuration.maxByOrNull { it.second }?.first + ?: return closed(Eligibility.MissingPrimarySleep) + + val start = subtractOrNull(primary.start, preSleepWindowSeconds.toLong()) + ?: return closed(Eligibility.InvalidWindow) + val inWindow = deduplicated(hr).filter { it.ts.toLong() >= start && it.ts.toLong() < primary.start } + val valid = inWindow.filter { + baselineCfg.minVal <= it.bpm.toDouble() && it.bpm.toDouble() <= baselineCfg.maxVal + } + if (valid.size < minimumValidSamples) { + return closed(Eligibility.InsufficientPreSleepSamples(valid.size, minimumValidSamples)) + } + // `toLong()` before summing, not after: `sumOf { it.bpm }` accumulates in Int and would WRAP + // silently, where Apple's `reduce` accumulates in 64-bit and cannot. Unreachable with a real + // window — 220 bpm needs ~9.7M samples to overflow — but a wrapped sum produces a plausible + // wrong mean rather than a failure, and this file guards its other arithmetic explicitly + // (`subtractOrNull`). Exact integer sum then divide, which is Apple's order too. + val mean = valid.sumOf { it.bpm.toLong() }.toDouble() / valid.size.toDouble() + val observation = Observation( + primarySleepStartTs = primary.start, primarySleepEndTs = primary.end, + windowStartTs = start, windowEndTs = primary.start, meanBpm = mean, + validSamples = valid.size, totalTimestampSamples = inWindow.size, + ) + // Baselines use prior nights only and `rollingMeanSD` requires oldest-to-newest input. Retain the + // first caller-supplied reading for a repeated canonical day; one day contributes at most one + // night and no synthetic average is invented. + val seenDays = mutableSetOf() + val priorHistory = history + .mapNotNull { reading -> + val date = canonicalDay(reading.day) + if (date != null && date.isBefore(evaluationDate) && seenDays.add(reading.day)) { + reading to date + } else { + null + } + } + .sortedBy { it.second } + val rollingBaseline = Baselines.rollingMeanSD(priorHistory.map { it.first.meanBpm }, baselineCfg) + val latestContributingDate = priorHistory.lastOrNull { + baselineCfg.minVal <= it.first.meanBpm && it.first.meanBpm <= baselineCfg.maxVal + }?.second + val nightsSinceUpdate = latestContributingDate + ?.let { maxOf(0L, ChronoUnit.DAYS.between(it, evaluationDate)).toInt() } ?: 0 + val baseline = BaselineState( + baseline = rollingBaseline.baseline, + spread = rollingBaseline.spread, + nValid = rollingBaseline.nValid, + nightsSinceUpdate = nightsSinceUpdate, + status = Baselines.computeStatus(rollingBaseline.nValid, nightsSinceUpdate), + ) + val context = journalEntries.filter { it.day == day }.map { + JournalFact(it.day, it.question, it.answeredYes, it.numericValue) + } + if (baseline.nValid < minimumBaselineNights) { + return closed( + Eligibility.InsufficientBaseline(baseline.nValid, minimumBaselineNights), + obs = observation, unc = listOf(Uncertainty.NoPersonalComparison), ctx = context, + ) + } + if (!baseline.usable) { + return closed( + Eligibility.StaleBaseline(baseline.nightsSinceUpdate), obs = observation, + unc = listOf(Uncertainty.StaleBaseline(baseline.nightsSinceUpdate)), ctx = context, + ) + } + val comparison = Comparison( + baselineBpm = baseline.baseline, deltaBpm = mean - baseline.baseline, + baselineNights = baseline.nValid, baselineStatus = baseline.status, + ) + val uncertainty = + if (baseline.status == BaselineStatus.TRUSTED) emptyList() + else listOf(Uncertainty.ProvisionalBaseline) + return Feedback( + eligibility = Eligibility.Eligible, observation = observation, comparison = comparison, + uncertainty = uncertainty, inference = Inference.NOT_ESTABLISHED, + recommendation = Recommendation.UNSUPPORTED, journalContext = context, + ) + } + + /** + * `a - b`, or null when it would overflow. + * + * NOT a twin claim, though it reads like one and originally said so. Apple expresses the same + * contract with `subtractingReportingOverflow`, which is Swift STDLIB on `FixedWidthInteger` and + * therefore not a declaration in this repository at all — so a reader chasing the reference finds + * nothing, and a parity audit resolves it to nothing either. + */ + private fun subtractOrNull(a: Long, b: Long): Long? = + try { Math.subtractExact(a, b) } catch (_: ArithmeticException) { null } + + /** + * A calendar day, or null when the string is not a canonical `YYYY-MM-DD`. + * + * The shape check alone is not enough: it accepts `2026-02-31`. [LocalDate.of] rejects an impossible + * day, which is what the Swift twin's round-trip through `Calendar` is doing. + */ + private fun canonicalDay(day: String): LocalDate? { + val bytes = day.toByteArray(Charsets.UTF_8) + if (bytes.size != 10 || bytes[4] != '-'.code.toByte() || bytes[7] != '-'.code.toByte()) return null + val digitPositions = intArrayOf(0, 1, 2, 3, 5, 6, 8, 9) + if (!digitPositions.all { bytes[it] in 48..57 }) return null + val year = (bytes[0] - 48) * 1_000 + (bytes[1] - 48) * 100 + (bytes[2] - 48) * 10 + (bytes[3] - 48) + val month = (bytes[5] - 48) * 10 + (bytes[6] - 48) + val dayOfMonth = (bytes[8] - 48) * 10 + (bytes[9] - 48) + return try { LocalDate.of(year, month, dayOfMonth) } catch (_: java.time.DateTimeException) { null } + } + + /** + * One sample per timestamp, FIRST occurrence wins. + * + * First-wins is the caller's precedence order, not an arbitrary pick: [evaluate]'s contract is an + * already-coalesced timeline where measured data outranks PPG-derived, so the first element for a + * timestamp is the one the repository chose. Stated here as well as on [evaluate] because the rule + * is invisible at this call site, and a later reader changing it to last-wins would silently invert + * that precedence (#1784). + */ + private fun deduplicated(hr: List): List { + val seen = mutableSetOf() + return hr.filter { seen.add(it.ts) } + } +} diff --git a/android/app/src/main/java/com/noop/analytics/RecoveryScorer.kt b/android/app/src/main/java/com/noop/analytics/RecoveryScorer.kt index 66db4ae3c6..43b85dfc33 100644 --- a/android/app/src/main/java/com/noop/analytics/RecoveryScorer.kt +++ b/android/app/src/main/java/com/noop/analytics/RecoveryScorer.kt @@ -253,143 +253,6 @@ object RecoveryScorer { ) } - /** Rolling-mean HR window (seconds) for the resting-HR estimate. */ - const val restingHRWindowS: Int = 5 * 60 - - /** - * Minimum HR samples a 5-min bin must hold before its mean is eligible to WIN the resting - * floor (#686). A thinly-populated bin — at the limit a single lone beat — lets one artifact - * (a dropout, a decode glitch) become the bin "mean" and win the night's minimum, dragging - * resting HR implausibly low. Requiring a handful of samples means the floor is a genuine - * sustained dip, not a one-sample fluke. Worn nights stream ~1 Hz HR so a real 5-min bin holds - * hundreds of samples and clears this trivially; only sparse/edge bins fall below it. Does NOT - * change the floor DEFINITION — still the min of 5-min bin means — only stops an under-sampled - * artifact bin from being that min. Mirrors Swift `RecoveryScorer.restingHRMinBinSamples`. - */ - const val restingHRMinBinSamples: Int = 5 - - /** - * Physiological resting-HR floor (bpm) below which a bin mean is rejected as a dropout artifact - * (#686), never the resting floor. An adult's true sleeping resting HR essentially never sits - * below this; a 5-min mean that does is a run of dropout/decode-zero beats, not a real cardiac - * dip. 25 bpm clears even deeply-bradycardic trained athletes (resting HRs in the low 30s) with - * margin while rejecting the implausible artifact range. Mirrors Swift - * `RecoveryScorer.restingHRMinPlausibleBpm`. - */ - const val restingHRMinPlausibleBpm: Double = 25.0 - - // ───────────────────────────────────────────────────────────────────────── - // Resting HR - // ───────────────────────────────────────────────────────────────────────── - - /** - * Lowest sustained HR during the in-bed window (bpm, rounded), or null. - * - * "Sustained" = the minimum of 5-minute non-overlapping bin means of the HR - * samples whose ts ∈ [start, end]. Rejects single-beat dips while capturing - * the night's true floor. Returns null when there are no HR samples in window. - * - * Artifact hardening (#686): a bin may only WIN the floor when it is BOTH well-populated - * (≥ [restingHRMinBinSamples], so one lone artifact beat can't be a bin "mean") AND - * physiologically plausible (mean ≥ [restingHRMinPlausibleBpm], rejecting dropout-driven - * sub-physiological dips). The floor DEFINITION is unchanged — still the minimum of the 5-min - * bin means — only artifact bins are barred from being that minimum. If no bin qualifies (a - * wholly sparse/degenerate window), fall back to the lowest of ALL bin means, else the - * all-sample mean, preserving the never-null-on-data behaviour. Mirrors Swift `restingHR`. - * - * @param start / @param end window bounds, unix SECONDS (Long). - */ - fun restingHR(hr: List, start: Long, end: Long): Int? { - val seg = hr.filter { it.ts in start..end } - if (seg.isEmpty()) return null - - val means = ArrayList() // every bin mean (legacy floor, the fallback) - val qualified = ArrayList() // bins eligible to WIN the floor (#686) - var t = start - while (t < end) { - val binEnd = t + restingHRWindowS - val win = seg.filter { it.ts >= t && it.ts < binEnd } - if (win.isNotEmpty()) { - val mean = win.sumOf { it.bpm }.toDouble() / win.size.toDouble() - means.add(mean) - // A bin wins the floor only if it is well-populated AND physiologically plausible — - // a thin (single-artifact) or sub-physiological (dropout) bin can't be the minimum. - if (win.size >= restingHRMinBinSamples && mean >= restingHRMinPlausibleBpm) { - qualified.add(mean) - } - } - t += restingHRWindowS - } - val floor: Double = qualified.minOrNull() - ?: means.minOrNull() - ?: (seg.sumOf { it.bpm }.toDouble() / seg.size.toDouble()) - return floor.roundToInt() - } - - // ───────────────────────────────────────────────────────────────────────── - // Recovery Index (overnight HR-decline slope) - // ───────────────────────────────────────────────────────────────────────── - - /** - * Minimum 5-minute bins ([restingHR]'s SAME binning) required before a slope is trusted — - * below this, too little of the night has elapsed to fit a trend, and a 1-2-point - * regression is noise, not a night-long pattern. 6 bins = 30 minutes of binned coverage, - * a deliberately low floor so a short/partial night still gets a number rather than a - * routine null. Mirrors Swift `RecoveryScorer.recoveryIndexMinBins`. - */ - const val recoveryIndexMinBins: Int = 6 - - /** - * Overnight resting-HR DECLINE slope (bpm/hour) across the in-bed window — the "Recovery - * Index" component of Oura's Readiness that Charge lacked (it previously only read the - * overnight FLOOR via [restingHR] above, never the trend that reaches it). - * - * Computed as the least-squares slope of the SAME non-overlapping 5-minute HR bin means - * [restingHR] uses ([restingHRWindowS]) against each bin's midpoint time (hours from - * `start`). NEGATIVE = declining (HR falling through the night — the physiologically - * expected, good pattern); POSITIVE = rising (restlessness, illness, alcohol, a late - * stimulant). Returns null when fewer than [recoveryIndexMinBins] bins have data (too - * little of the window to fit a trend) or there are no samples at all — it never - * fabricates a slope from a sliver of the night. Mirrors Swift `recoveryIndexSlope`. - * - * @param start / @param end window bounds, unix SECONDS (Long). - */ - fun recoveryIndexSlope(hr: List, start: Long, end: Long): Double? { - val seg = hr.filter { it.ts in start..end } - if (seg.isEmpty()) return null - - // Same non-overlapping 5-minute binning as restingHR: both read the identical - // underlying series, one as a floor, one as a trend across it. - val points = ArrayList>() // (tHours, meanBpm) - var t = start - while (t < end) { - val binEnd = t + restingHRWindowS - val win = seg.filter { it.ts >= t && it.ts < binEnd } - if (win.isNotEmpty()) { - val mean = win.sumOf { it.bpm }.toDouble() / win.size.toDouble() - val midpointS = (t - start).toDouble() + restingHRWindowS / 2.0 - points.add((midpointS / 3600.0) to mean) - } - t += restingHRWindowS - } - if (points.size < recoveryIndexMinBins) return null - - // Least-squares slope: Σ((t−t̄)(y−ȳ)) / Σ((t−t̄)²), bpm per hour. - val n = points.size.toDouble() - val tBar = points.sumOf { it.first } / n - val yBar = points.sumOf { it.second } / n - var num = 0.0 - var den = 0.0 - for ((tHours, meanBpm) in points) { - val dt = tHours - tBar - num += dt * (meanBpm - yBar) - den += dt * dt - } - // Degenerate (all bins at the same instant): no time spread to fit against. - if (den <= 1e-9) return 0.0 - return num / den - } - // ───────────────────────────────────────────────────────────────────────── // Recovery band // ───────────────────────────────────────────────────────────────────────── @@ -440,8 +303,8 @@ object RecoveryScorer { * identical to the pre-skin-temp model). * @param hrvBaselineUsable whether the HRV baseline has enough nights * (BaselineState.usable). When false, returns null (cold-start). - * @param recoveryIndexSlope overnight resting-HR DECLINE slope (bpm/hour, from - * [recoveryIndexSlope]). Negative (declining) supports recovery; positive (rising) + * @param recoveryIndexSlope overnight resting-HR DECLINE slope (bpm/hour), supplied by the + * caller. Negative (declining) supports recovery; positive (rising) * limits it, weight [wRecoveryIndex]. null (the default) drops the term and the * weights renormalize, so the no-slope score is IDENTICAL to before this parameter * existed. diff --git a/android/app/src/main/java/com/noop/analytics/RegistryDayOwnerSource.kt b/android/app/src/main/java/com/noop/analytics/RegistryDayOwnerSource.kt index 423f273e9e..47ba59f82e 100644 --- a/android/app/src/main/java/com/noop/analytics/RegistryDayOwnerSource.kt +++ b/android/app/src/main/java/com/noop/analytics/RegistryDayOwnerSource.kt @@ -10,18 +10,17 @@ import com.noop.protocol.DeviceFamily * with the per-day owner-resolution inputs so a day is scored from exactly ONE device (invariant I2), * without giving the pure-JVM engine a Room dependency. * - * Priorities mirror the Swift IntelligenceEngine.resolveDayOwner exactly: - * 0 = the active strap, 1 = other live (BLE/historyBLE) straps, 2 = imports (cloud/file). Lower wins. - * Archived devices are excluded. With only the seeded active 'my-whoop' row paired (the default and - * every single-WHOOP install), the sole candidate is priority 0, so the engine resolves to "my-whoop" - * for every day and the reads stay byte-identical to the single-source path. + * Priorities mirror the Swift IntelligenceEngine.resolveDayOwner for current sources, with priority 4 + * reserved for archived devices. Archived rows remain read-only historical candidates so removing and + * later re-adding a strap cannot erase its earlier physiological boundary/coverage; every current live, + * import, and activity source still wins an overlap. With only the seeded active 'my-whoop' row paired + * (the default and every single-WHOOP install), the sole candidate is priority 0, so reads stay identical. */ class RegistryDayOwnerSource(private val registry: DeviceRegistry) : IntelligenceEngine.DayOwnerSource { override suspend fun candidatePriorities(): List> { val activeId = registry.activeDeviceId() return registry.all() - .filter { it.status != DeviceStatus.archived.name } .map { d -> val isImport = d.sourceKind == SourceKind.cloudImport.name || d.sourceKind == SourceKind.fileImport.name @@ -30,6 +29,7 @@ class RegistryDayOwnerSource(private val registry: DeviceRegistry) : Intelligenc // wins a day nothing else covers. Mirrors Swift IntelligenceEngine.resolveDayOwner. val priority = when { d.id == activeId -> 0 + d.status == DeviceStatus.archived.name -> 4 d.sourceKind == SourceKind.activityFile.name -> 3 isImport -> 2 else -> 1 diff --git a/android/app/src/main/java/com/noop/analytics/RrEmissionStats.kt b/android/app/src/main/java/com/noop/analytics/RrEmissionStats.kt index a8edd47542..2737eb339e 100644 --- a/android/app/src/main/java/com/noop/analytics/RrEmissionStats.kt +++ b/android/app/src/main/java/com/noop/analytics/RrEmissionStats.kt @@ -113,6 +113,33 @@ object RrEmissionStats { return Result(bySecond.size, rr.size, sum, span, ratio, hist, gapHist, fill) } + /** + * Should a LIVE census line be emitted now? (#1118 instrumentation.) + * + * The historical census is emitted once per offload SESSION, which is naturally rare. The two live + * paths flush roughly every 30 readings, so emitting per flush would put a line on the strap log + * every ~30 seconds all night — a volume that buries the very evidence it exists to provide. One + * line per path per [minGapSec] is plenty: a single flush already spans enough seconds to make + * `ratioRep` meaningful (the historical line settles it on 39 s), and what matters is comparing the + * paths, not sampling either densely. + * + * [lastEmitSec] is 0 before the first line, so the first flush of a connection always reports. + * A clock that moves BACKWARDS emits rather than suppresses: the alternative wedges the census + * shut until wall time catches up, and a silent instrument is worse than a duplicated line. + */ + fun shouldEmitLiveCensus(lastEmitSec: Int, nowSec: Int, minGapSec: Int = LIVE_CENSUS_MIN_GAP_SEC): Boolean { + // The "never emitted" sentinel is checked EXPLICITLY. Falling through to the gap arithmetic + // gets the right answer in production only because a real unix `nowSec` is ~1.8e9 and so + // trivially clears any gap — an accident of magnitude, not a rule. It also made the helper + // untestable with small timestamps, which is how this was caught. + if (lastEmitSec <= 0) return true + if (nowSec < lastEmitSec) return true + return nowSec - lastEmitSec >= minGapSec + } + + /** 15 minutes: rare enough not to bury the log, frequent enough to sample a night several times. */ + const val LIVE_CENSUS_MIN_GAP_SEC: Int = 900 + /** * One compact log line. [offered]/[inserted] come from the caller: [inserted] is what the store * actually wrote after its conflict key, so `offered - inserted` is how much the primary key already @@ -126,11 +153,16 @@ object RrEmissionStats { * rather than by the wall span, so it is immune to gaps; the two agreeing means the batch is gapless, * and `ratioRep` is the one to trust when they disagree. */ - fun logLine(path: String, offered: Int, inserted: Int, r: Result): String { + fun logLine(path: String, offered: Int, inserted: Int?, r: Result): String { val ratio = String.format(java.util.Locale.US, "%.2f", r.ratio) + // NULL renders "n/a", never a number. Only the historical path can see what the store's conflict + // key actually kept; the live paths census BEFORE the insert and have no such count. Echoing + // `offered` there would print `offered=N inserted=N`, which reads as "the primary key absorbed + // nothing" — a measurement neither path made. Same reason GpsSession passes rawFixes = null. + val ins = inserted?.toString() ?: "n/a" val rep = if (r.secondsWithRr > 0) r.sumRrMs / 1000.0 / r.secondsWithRr else 0.0 val h = r.perSecond - return "rr emit path=$path offered=$offered inserted=$inserted secs=${r.secondsWithRr} " + + return "rr emit path=$path offered=$offered inserted=$ins secs=${r.secondsWithRr} " + "sumRr=${r.sumRrMs / 1000}s span=${r.spanSec}s ratio=$ratio " + "ratioRep=${String.format(java.util.Locale.US, "%.2f", rep)} " + "perSec[1/2/3/4+]=${h[0]}/${h[1]}/${h[2]}/${h[3]} " + diff --git a/android/app/src/main/java/com/noop/analytics/ScoreConfidence.kt b/android/app/src/main/java/com/noop/analytics/ScoreConfidence.kt index 1a76bc1c87..2234a7b74b 100644 --- a/android/app/src/main/java/com/noop/analytics/ScoreConfidence.kt +++ b/android/app/src/main/java/com/noop/analytics/ScoreConfidence.kt @@ -117,18 +117,30 @@ enum class ScoreConfidence(val raw: String) { const val highEfficiencyThreshold: Double = 0.85 /** - * Rest confidence WITH the H9 stage-quality check AND the sparse-motion guard. Starts from - * [forRest], then DOWNGRADES a [SOLID] tier to [BUILDING] (low-confidence) when EITHER: + * Rest confidence WITH the H9 stage-quality check, the sparse-motion guard AND the + * hypnogram-coverage guard. Starts from [forRest], then DOWNGRADES a [SOLID] tier to [BUILDING] + * (low-confidence) when ANY of: * - the night was staged on SPARSE gravity ([gravitySparse]) — a WHOOP 4.0 synced/offload night * banks motion coarsely, too sparse to reliably stage sleep (#345), so a confident 85–100 Rest * is unearned however the engine filled the stages. This catches the case H9 MISSES: a sparse * night whose staging manufactures HIGH efficiency AND HIGH restorative reads SOLID under H9 * alone (the #319 signature), yet the underlying data can't support it; OR * - the night is high-efficiency yet its restorative (deep+REM) share is below - * [restorativeLowConfidenceShare] — a likely staging miss (#H9). - * [CALIBRATING]/[BUILDING] from the base call are returned unchanged. Engine output only; the UI - * surfaces the tier later. Confidence-only — it never changes the Rest score or invents stages. - * Mirrors Swift `rest(hasSession:hasStagedSleep:asleepSeconds:restorativeSeconds:efficiency:gravitySparse:)`. + * [restorativeLowConfidenceShare] — a likely staging miss (#H9); OR + * - [stageCoverage] says the stage timeline accounts for less than + * [HypnogramCoverage.minCoverage] of the span it claims. This is the case the other two + * structurally cannot see: a device-PROVIDED hypnogram assembled from records that arrived + * incomplete has real stages over the part that DID arrive, so its restorative share is + * ordinary and H9 stays quiet, while [gravitySparse] describes the on-device motion stager and + * is false for a provided hypnogram (and inert for Oura outright, which banks no gravity at + * all). Measured: a ring night covering 23% of its 601-minute span was stored as 70 minutes of + * sleep and reported SOLID. + * [stageCoverage] is null when coverage is unknown or not applicable — the guard fails OPEN + * there, so an unmeasurable payload keeps its previous tier rather than being downgraded on no + * evidence. [CALIBRATING]/[BUILDING] from the base call are returned unchanged. Engine output + * only; the UI surfaces the tier later. Confidence-only — it never changes the Rest score, + * invents stages, or claims the uncovered time was awake. Mirrors Swift + * `rest(hasSession:hasStagedSleep:asleepSeconds:restorativeSeconds:efficiency:gravitySparse:stageCoverage:)`. * (#H9, #345) */ fun forRest( @@ -138,10 +150,13 @@ enum class ScoreConfidence(val raw: String) { restorativeSeconds: Double, efficiency: Double, gravitySparse: Boolean = false, + stageCoverage: Double? = null, ): ScoreConfidence { val base = forRest(hasSession, hasStagedSleep) if (base != SOLID) return base if (gravitySparse) return BUILDING // #345: sparse-motion staging can't earn a SOLID Rest + // the timeline covers only part of the night it claims + if (stageCoverage != null && stageCoverage < HypnogramCoverage.minCoverage) return BUILDING if (asleepSeconds <= 0.0) return base val restorativeShare = restorativeSeconds / asleepSeconds return if (efficiency >= highEfficiencyThreshold && restorativeShare < restorativeLowConfidenceShare) { diff --git a/android/app/src/main/java/com/noop/analytics/SkinTempDisplay.kt b/android/app/src/main/java/com/noop/analytics/SkinTempDisplay.kt index d6564aff5d..4789a8b016 100644 --- a/android/app/src/main/java/com/noop/analytics/SkinTempDisplay.kt +++ b/android/app/src/main/java/com/noop/analytics/SkinTempDisplay.kt @@ -12,16 +12,87 @@ package com.noop.analytics */ object SkinTempDisplay { - enum class Kind { + /** + * [raw] is the PERSISTED form of the #1846 setting and is byte-identical to the Swift `Kind.rawValue` + * — lower-case, so `name` ("DEVIATION") must never be written to prefs. Same reasoning as + * `KeyMetric.raw`: two independent implementations must not drift on how they spell a stored token, + * and this one shares the `units.skinTempDisplay` key with Apple. + */ + enum class Kind(val raw: String) { /** Absolute wrist temperature (°C scale, typically ~30–35 worn). */ - ABSOLUTE, + ABSOLUTE("absolute"), /** Signed deviation from the personal nightly baseline (±°C). */ - DEVIATION, + DEVIATION("deviation"), + ; + + companion object { + fun fromRaw(raw: String?): Kind? = entries.firstOrNull { it.raw == raw } + } } + /** + * The one kind a MIXED series must be reduced to before any aggregate is taken (#1705). + * + * A CSV import writes absolute °C and the computed pipeline writes a deviation, both into this + * same field, so one window can hold both. Formatting stays honest per value — [kind] is + * re-derived for each — but a min, max, mean or window-over-window delta drawn across both is + * arithmetic on two different scales, and it looks plausible: a handful of ~34 °C readings lift a + * should-be-near-zero deviation average past a full degree, and the mean is then itself below 20 + * so it gets labelled Δ°C. + * + * The newest entry decides, matching what `HealthVitalsLogic` already does for its sparkline — the + * reading the user is actually looking at sets the scale, and older entries of the other kind drop + * out rather than being converted, because converting needs a baseline that a fresh import-only + * install does not have. + * + * @param valuesAscendingByDay the window's values, ascending by day. + * @return the kind to keep, or null for an empty window. + */ + fun dominantKind(valuesAscendingByDay: List): Kind? = + valuesAscendingByDay.lastOrNull()?.let { kind(it) } + fun kind(value: Double): Kind = if (VitalBands.isAbsoluteSkinTemp(value)) Kind.ABSOLUTE else Kind.DEVIATION + /** A resolved reading: the number to show and the scale it is on. */ + data class Reading(val value: Double, val kind: Kind) + + /** + * Which of a night's two skin-temp numbers a surface should LEAD with (#1844), the pure form of the + * rule `HealthVitalsLogic.skinTempLeadsWithAbsolute` has applied since #1665. + * + * The absolute wins whenever the night measured one, because a deviation with no anchor cannot be + * read — "+0.9" is a fever or a warm bedroom and nothing on a card says which. The deviation is the + * fallback, not the default, and it still carries its Δ unit so it is never mistaken for a wrist + * temperature (#622). + * + * Two shapes make this more than a preference, and both are real rows rather than hypotheticals: + * - a night scored before `skinTempC` shipped (2026-08-27) has only a deviation, and keeps exactly + * the display that shipped before until a scoring pass refills it; + * - a CALIBRATING night is the reverse — `recomputeSkinTempDev` returns null until the baseline is + * usable (~4 nights) while the absolute is measured from night one, so those wearers otherwise see + * an empty card with a real temperature sitting behind it. + * + * [prefer] is the user's Settings choice (#1846): ABSOLUTE — the default, a temperature — or DEVIATION + * for wearers who read the ±baseline move, which is the more sensitive illness signal. It picks which + * number is tried FIRST; the other is still the fallback, so choosing one never blanks a card whose + * night only measured the other, and the unit always names the scale actually shown. + * + * Both values must come from the SAME row; see `lastSkinTempReadingRow`. Twin of the Swift + * `SkinTempDisplay.leadReading`. + */ + fun leadReading(absC: Double?, devC: Double?, prefer: Kind = Kind.ABSOLUTE): Reading? { + val first = if (prefer == Kind.ABSOLUTE) absC else devC + first?.let { return Reading(it, prefer) } + val other = if (prefer == Kind.ABSOLUTE) devC else absC + val otherKind = if (prefer == Kind.ABSOLUTE) Kind.DEVIATION else Kind.ABSOLUTE + return other?.let { Reading(it, otherKind) } + } + + /** Formats whichever number [leadReading] chose, with the unit for THAT scale. */ + fun formatReading(reading: Reading, fahrenheit: Boolean, decimals: Int = 1): String = + "${numberString(reading.value, reading.kind, fahrenheit, decimals)} ${unitSymbol(reading.kind, fahrenheit)}" + /** Trailing unit chip: `"°C"` / `"°F"` for absolute, `"Δ°C"` / `"Δ°F"` for deviation. */ fun unitSymbol(kind: Kind, fahrenheit: Boolean): String { val base = if (fahrenheit) "°F" else "°C" @@ -43,11 +114,15 @@ object SkinTempDisplay { } else { value } - return if (kind == Kind.ABSOLUTE) { - String.format(java.util.Locale.US, "%.${decimals}f", display) - } else { - String.format(java.util.Locale.US, "%+.${decimals}f", display) + if (kind == Kind.ABSOLUTE) { + return String.format(java.util.Locale.US, "%.${decimals}f", display) } + val signed = String.format(java.util.Locale.US, "%+.${decimals}f", display) + // #1842: a deviation that ROUNDS to zero must not keep its sign. `%+` prints "-0.0" for anything + // in (-0.05, 0), and "-0.0 Δ°C" on a card reads as a fault rather than as "no change from your + // baseline" — which is exactly what it means. Drop the sign only when the rounded value is zero; + // a real -0.1 keeps it. + return if (signed.drop(1).toDoubleOrNull() == 0.0) signed.drop(1) else signed } /** Full `"34.2 °C"` / `"+0.1 Δ°C"` string for one-shot call sites (Today cards, explorers). */ diff --git a/android/app/src/main/java/com/noop/analytics/SleepAwareStepCounter.kt b/android/app/src/main/java/com/noop/analytics/SleepAwareStepCounter.kt new file mode 100644 index 0000000000..d71373b8f2 --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/SleepAwareStepCounter.kt @@ -0,0 +1,211 @@ +package com.noop.analytics + +import com.noop.data.GravitySample +import com.noop.data.StepSample +import com.noop.data.V18AuxRow + +/** + * Sleep-context wrapper around [StepsCounter]. It keeps the incumbent counter/class/rate rules, but does + * not accept an isolated strap "walk" tick as proof that a sleeping person walked. Inside a scored sleep + * stage, valid locomotion deltas must form a short, temporally coherent bout. An explicitly scored wake + * gap is intentionally treated like ordinary awake time, so getting up for a child or the toilet counts. + * + * This kernel returns raw counter ticks exactly as [StepsCounter] does. It deliberately applies neither a + * learned conversion factor nor a fixed wrist orientation: a strap may be worn on either arm, a limb or + * clothing. Gravity/dynamic-acceleration and v18 auxiliary data are accepted for observability and future + * shadow validation, but are not yet trusted as production gates. When either stream is absent, the + * conservative temporal bout rule remains deterministic and fail-safe. + */ +object SleepAwareStepCounter { + /** A one- or two-second bed movement cannot satisfy all three bout floors. */ + const val MAX_SECONDS_BETWEEN_GAIT_DELTAS: Long = 3L + const val MIN_GAIT_BOUT_DURATION_SECONDS: Long = 4L + const val MIN_GAIT_BOUT_ACTIVE_SAMPLES: Int = 5 + const val MIN_GAIT_BOUT_TICKS: Int = 6 + + data class Count( + val totalTicks: Int, + val acceptedOutsideSleepTicks: Int, + val acceptedAwakeGapTicks: Int, + val acceptedSleepBoutTicks: Int, + val rejectedIsolatedSleepTicks: Int, + val rejectedActivityClassTicks: Int, + val rejectedImplausibleTicks: Int, + /** Availability only; neither raw stream changes the production decision yet. */ + val gravitySamplesAvailable: Int, + val auxSamplesAvailable: Int, + ) { + operator fun plus(other: Count): Count = Count( + totalTicks + other.totalTicks, + acceptedOutsideSleepTicks + other.acceptedOutsideSleepTicks, + acceptedAwakeGapTicks + other.acceptedAwakeGapTicks, + acceptedSleepBoutTicks + other.acceptedSleepBoutTicks, + rejectedIsolatedSleepTicks + other.rejectedIsolatedSleepTicks, + rejectedActivityClassTicks + other.rejectedActivityClassTicks, + rejectedImplausibleTicks + other.rejectedImplausibleTicks, + gravitySamplesAvailable + other.gravitySamplesAvailable, + auxSamplesAvailable + other.auxSamplesAvailable, + ) + + companion object { + val EMPTY = Count(0, 0, 0, 0, 0, 0, 0, 0, 0) + } + } + + /** Preserve scored stages while edited leading/trailing edges remain conservatively inside sleep. */ + fun withBounds(session: DetectedSleep, start: Long, end: Long): DetectedSleep = + session.copy(start = start, end = end) + + /** Effective bounds + stage content affecting exactly this cycle; unrelated sleep edits stay out. */ + fun contextSignature(sessions: List, onset: Long, endExclusive: Long): String = + sessions.asSequence() + .filter { it.end > onset && it.start < endExclusive } + .sortedWith(compareBy { it.start }.thenBy { it.end }) + .joinToString("|") { session -> + buildString { + append(session.start).append('-').append(session.end) + for (stage in session.stages.sortedBy { it.start }) { + append(':').append(stage.start).append('-').append(stage.end).append('=').append(stage.stage) + } + } + } + + /** Same nullable convention as [StepsCounter.stepsInWindow]: no accepted movement is `null`. */ + fun stepsInWindow( + samples: List, + sleepSessions: List, + gravity: List = emptyList(), + aux: List = emptyList(), + ): Int? = count(samples, sleepSessions, gravity, aux).totalTicks.takeIf { it > 0 } + + /** Detailed form intended for the Test Centre/shadow trace. */ + fun count( + samples: List, + sleepSessions: List, + gravity: List = emptyList(), + aux: List = emptyList(), + ): Count { + val sorted = samples.sortedBy { it.ts } + return Accumulator(sleepSessions, StepsCounter.hasActivityClasses(sorted)) + .observeMotionPage(gravity, aux) + .acceptPage(sorted) + .finish() + } + + /** + * Page-safe form for database windows larger than an in-memory query limit. The caller determines + * [hasActivityClasses] once for the complete window (for example with an EXISTS query) and keeps one + * accumulator for every ascending page. The previous counter sample and an unfinished in-sleep gait + * bout survive page boundaries, so paging cannot change the answer. + * + * Pages may overlap at their boundary: timestamps already consumed are ignored. Calling [finish] + * closes the last gait bout and makes the accumulator immutable. + */ + class Accumulator( + sleepSessions: List, + private val hasActivityClasses: Boolean, + ) { + private val sessions = sleepSessions.sortedBy { it.start } + private var previous: StepSample? = null + private var outside = 0 + private var awakeGap = 0 + private var sleepBout = 0 + private var rejectedSleep = 0 + private var rejectedClass = 0 + private var rejectedImplausible = 0 + private var gravityAvailable = 0 + private var auxAvailable = 0 + private var finished = false + private val pendingSleepBout = ArrayList() + + fun observeMotionPage( + gravity: List = emptyList(), + aux: List = emptyList(), + ): Accumulator = apply { + check(!finished) { "Accumulator is already finished" } + gravityAvailable += gravity.size + auxAvailable += aux.size + } + + fun acceptPage(samples: List): Accumulator = apply { + check(!finished) { "Accumulator is already finished" } + for (current in samples.sortedBy { it.ts }) { + val prior = previous + if (prior != null && current.ts <= prior.ts) continue + previous = current + if (prior == null) continue + + val delta = (current.counter - prior.counter) and 0xFFFF + if (!StepsCounter.shouldCountDelta(current.activityClass, hasActivityClasses)) { + rejectedClass += delta + continue + } + if (!StepsCounter.isPlausibleDelta(prior.ts, current.ts, delta)) { + rejectedImplausible += delta + continue + } + + when (contextAt(current.ts, sessions)) { + Context.OUTSIDE_SLEEP -> { + flushSleepBout() + outside += delta + } + Context.AWAKE_GAP -> { + flushSleepBout() + awakeGap += delta + } + Context.SCORED_SLEEP -> { + val last = pendingSleepBout.lastOrNull() + if (last != null && current.ts - last.ts > MAX_SECONDS_BETWEEN_GAIT_DELTAS) { + flushSleepBout() + } + pendingSleepBout += Delta(current.ts, delta) + } + } + } + } + + fun finish(): Count { + if (!finished) { + flushSleepBout() + finished = true + } + return Count( + totalTicks = outside + awakeGap + sleepBout, + acceptedOutsideSleepTicks = outside, + acceptedAwakeGapTicks = awakeGap, + acceptedSleepBoutTicks = sleepBout, + rejectedIsolatedSleepTicks = rejectedSleep, + rejectedActivityClassTicks = rejectedClass, + rejectedImplausibleTicks = rejectedImplausible, + gravitySamplesAvailable = gravityAvailable, + auxSamplesAvailable = auxAvailable, + ) + } + + private fun flushSleepBout() { + if (pendingSleepBout.isEmpty()) return + val ticks = pendingSleepBout.sumOf { it.ticks } + val duration = pendingSleepBout.last().ts - pendingSleepBout.first().ts + val coherent = pendingSleepBout.size >= MIN_GAIT_BOUT_ACTIVE_SAMPLES && + duration >= MIN_GAIT_BOUT_DURATION_SECONDS && + ticks >= MIN_GAIT_BOUT_TICKS + if (coherent) sleepBout += ticks else rejectedSleep += ticks + pendingSleepBout.clear() + } + } + + private data class Delta(val ts: Long, val ticks: Int) + private enum class Context { OUTSIDE_SLEEP, AWAKE_GAP, SCORED_SLEEP } + + private fun contextAt(ts: Long, sessions: List): Context { + val session = sessions.firstOrNull { ts >= it.start && ts < it.end } ?: return Context.OUTSIDE_SLEEP + val stage = session.stages.firstOrNull { ts >= it.start && ts < it.end } + return if (stage != null && SleepStageVocabulary.isWake(stage.stage)) { + Context.AWAKE_GAP + } else { + // Missing/gapped staging is not evidence of wakefulness. This is intentionally conservative. + Context.SCORED_SLEEP + } + } +} diff --git a/android/app/src/main/java/com/noop/analytics/SleepDebt.kt b/android/app/src/main/java/com/noop/analytics/SleepDebt.kt index 7e8ae2966b..6ed915e9a8 100644 --- a/android/app/src/main/java/com/noop/analytics/SleepDebt.kt +++ b/android/app/src/main/java/com/noop/analytics/SleepDebt.kt @@ -5,21 +5,20 @@ import kotlin.math.ceil import kotlin.math.floor /* - * SleepDebt.kt — a rolling sleep-debt ledger over the last N nights. + * SleepDebt.kt — an actionable next-night sleep-debt estimate over the last N nights. * * Faithful Kotlin mirror of StrandAnalytics/SleepDebt.swift. Keep the window cap, - * the skip-no-data rule, and the Σ(slept − need) accumulation byte-identical to + * the skip-no-data rule, and the debt recurrence byte-identical to * Swift — the two clients must report the same balance for the same nights. * * Pure, deterministic, DB-free. Given a chronological series of per-night total * sleep (minutes) and a personal sleep need (hours), it accumulates a running - * balance of (actual − need) per night across a capped trailing window (14 nights - * by default) and reports the net balance plus the per-night deltas behind it. + * debt from unmet current need across a capped trailing window (14 nights by + * default) and reports that debt plus the raw per-night deltas behind it. * * HONEST by construction: - * - A plain debt accumulator — sum of nightly (slept − need) — NOT a physiological - * model. A surplus night genuinely offsets a deficit one, like a balance nets - * credits and debits. + * - Debt is 55% of the current unmet need. Meeting need (including carried debt) + * clears it; extra sleep never creates a positive bank. * - The window is capped (default 14) so debt never compounds across months of * history — only the recent fortnight is in scope. * - Nights with no usable sleep total are SKIPPED (no zero-fill), so a gap in wear @@ -48,7 +47,7 @@ data class SleepDebtNight( * `SleepDebtLedger`. */ data class SleepDebtLedger( - /** Net running balance (minutes): Σ(slept − need). Negative = net DEBT, positive = net SURPLUS. */ + /** Current debt as a negative balance in minutes; never positive. */ val balanceMin: Double, /** Per-night contributions, oldest → newest (skipped nights absent); the per-night bar/spark. */ val nights: List, @@ -74,10 +73,12 @@ object SleepDebt { const val DEFAULT_WINDOW_NIGHTS: Int = 14 /** - * "On target" deadband (minutes): a |balance| under this reads as balanced rather than - * a debt/surplus, so a few stray minutes don't flip the headline. + * Calculated debts below this threshold are treated as balanced. Exactly ten minutes remains debt. */ - const val ON_TARGET_BAND_MIN: Double = 30.0 + const val ON_TARGET_BAND_MIN: Double = 10.0 + + /** Fraction of current unmet need carried into the next night's target. */ + const val DEBT_CARRY: Double = 0.55 /** * Sleep credited toward debt for one day. [mainSleepMin] remains the canonical @@ -94,6 +95,34 @@ object SleepDebt { return main + napSleepMin.coerceAtLeast(0.0) } + /** + * Debt values oldest → newest for local fallback surfaces. An imported value wins verbatim + * for its day's output, while the independent local recurrence continues from credited sleep. + */ + fun debtSeries( + series: List>, + needHours: Double = RestScorer.defaultSleepNeedHours, + importedDebtMin: Map = emptyMap(), + window: Int = DEFAULT_WINDOW_NIGHTS, + ): List> { + val cap = window.coerceAtLeast(1) + val usableHistory = ArrayList>(cap) + val result = ArrayList>(series.size) + for ((day, slept) in series) { + val imported = importedDebtMin[day] + val sleptMin = slept?.takeIf { it > 0.0 } + if (sleptMin != null) { + usableHistory.add(day to sleptMin) + if (usableHistory.size > cap) usableHistory.removeAt(0) + } + if (imported != null) result.add(day to imported) + else if (sleptMin != null) { + result.add(day to ledger(usableHistory, needHours = needHours, window = cap).magnitudeMin) + } + } + return result + } + /** * Build the ledger from a chronological `List>` series. * @@ -105,8 +134,8 @@ object SleepDebt { * @param window how many of the most-recent COUNTED nights to include. Defaults to * [DEFAULT_WINDOW_NIGHTS] (14). Clamped to ≥ 1. * - * The balance is Σ over the window of (sleptMin − needMin): a surplus night offsets a - * deficit one. Returns an empty ledger (balance 0, no nights) when no night has data. + * Each night applies `nextDebt = 0.55 * max(0, need + currentDebt - slept)`. + * Calculated debt below ten minutes is cleared. Returns an empty ledger when no night has data. */ fun ledger( series: List>, @@ -122,14 +151,19 @@ object SleepDebt { val windowed = usable.takeLast(cap) val nights = ArrayList(windowed.size) - var balance = 0.0 + var debt = 0.0 for ((day, slept) in windowed) { val sleptMin = slept ?: 0.0 val delta = sleptMin - needMin - balance += delta + debt = nextDebt(needMin, debt, sleptMin) nights.add(SleepDebtNight(day = day, sleptMin = sleptMin, deltaMin = delta)) } - return SleepDebtLedger(balanceMin = round1(balance), nights = nights, needMin = needMin) + return SleepDebtLedger(balanceMin = -round1(debt), nights = nights, needMin = needMin) + } + + private fun nextDebt(needMin: Double, currentDebt: Double, sleptMin: Double): Double { + val calculatedDebt = DEBT_CARRY * (needMin + currentDebt - sleptMin).coerceAtLeast(0.0) + return if (calculatedDebt < ON_TARGET_BAND_MIN) 0.0 else calculatedDebt } /** diff --git a/android/app/src/main/java/com/noop/analytics/SleepStageHealer.kt b/android/app/src/main/java/com/noop/analytics/SleepStageHealer.kt index b7a5698517..188e52415e 100644 --- a/android/app/src/main/java/com/noop/analytics/SleepStageHealer.kt +++ b/android/app/src/main/java/com/noop/analytics/SleepStageHealer.kt @@ -69,11 +69,11 @@ object SleepStageHealer { ): String? { val lo = start - 3_600L val hi = end + 3_600L - val grav = repo.gravitySamples(deviceId, lo, hi, IntelligenceEngine.STREAM_LIMIT) + val grav = repo.gravitySamplesForDevice(deviceId, lo, hi, IntelligenceEngine.STREAM_LIMIT) // Cheap density gate FIRST (count only) so a sparse imported night skips the three further reads. if (!isDense(grav, start, end)) return null - val hr = repo.hrSamples(deviceId, lo, hi, IntelligenceEngine.STREAM_LIMIT) - val rr = repo.rrIntervals(deviceId, lo, hi, IntelligenceEngine.STREAM_LIMIT) + val hr = repo.hrSamplesForDevice(deviceId, lo, hi, IntelligenceEngine.STREAM_LIMIT) + val rr = repo.rrIntervalsForDevice(deviceId, lo, hi, IntelligenceEngine.STREAM_LIMIT) // Same provenance refusal as the nightly scan: an Oura ring's respiration rows are its own // per-window RATE stored as instrumentation, not the ~1 Hz raw ADC waveform this stager reads, // so they never reach a re-stage either. See `OuraRespScale.forScoring`. Mirrors Swift. @@ -161,7 +161,7 @@ object SleepStageHealer { useMotionAwareWake: Boolean = false, ): List { suspend fun editedRows(): List = - repo.sleepSessions(computedDeviceId, windowStart, windowEnd).filter { it.userEdited } + repo.sleepSessionsForDevice(computedDeviceId, windowStart, windowEnd).filter { it.userEdited } val edited = editedRows() if (edited.isEmpty()) return emptyList() diff --git a/android/app/src/main/java/com/noop/analytics/SleepStager.kt b/android/app/src/main/java/com/noop/analytics/SleepStager.kt index 5a2067fbe6..7ab0fcb9dd 100644 --- a/android/app/src/main/java/com/noop/analytics/SleepStager.kt +++ b/android/app/src/main/java/com/noop/analytics/SleepStager.kt @@ -48,6 +48,18 @@ import kotlin.math.sqrt */ object SleepStager { + // ── #1943: RHR bin-gate thresholds (shared by sessionRestingHR and rhrBinGateLogLine) ────── + // One constant per threshold, read by both the gate and its conformance check, so a drift + // between the two is structurally impossible rather than merely documented. + + /** Minimum samples in a 5-min bin for it to qualify as a candidate for the night's resting HR. + * A one-sample bin at the edge of a wear gap cannot become the floor. */ + const val rhrMinBinSamples: Int = 5 + + /** Minimum plausible mean HR (bpm) for a bin to qualify. A dropout-driven sub-physiological + * dip cannot become the floor. */ + const val rhrMinPlausibleBpm: Double = 25.0 + // ── Stage 0 constants (sleep.py) ───────────────────────────────────────── /** Per-sample gravity change (g) at/below which a sample is "still". */ @@ -320,6 +332,48 @@ object SleepStager { */ const val sparseBridgeGapMin: Int = 90 + /** + * A single intervening ACTIVE run up to this long may be absorbed when bridging two sleep runs + * (#1657). + * + * This started as [maxGapMin] (20), on the reasoning that the file already had a threshold for + * "a discontinuity this long is decisive". An end-to-end test through [detectSleep] showed that was + * too tight to reach the case the issue is about: a FIFTEEN-minute interruption produced a + * TWENTY-ONE-minute active run, because [classifyStill] smears the still/moving boundary by roughly + * its rolling window and [buildRuns] closes runs at sample edges. The detected run is systematically + * longer than the interruption it represents, so a bound set from the interruption's true length + * rejects it. + * + * 30 is a judgement, and stated as one rather than dressed up as derived: a realistic trip out of + * bed of up to about a quarter of an hour, plus the ~6 minutes of smear that measurement showed, + * with a little headroom. It is deliberately well under [minSleepMin] — an interruption long enough + * to be a session in its own right is a genuine awakening and should split the night. + * + * The bound is the cheap half of the guard. The real one is the HR band across the whole span: a + * wearer who is actually up keeps HR elevated for the duration and fails it, while a brief stir does + * not. [mergeMin] (15) already absorbs shorter active runs upstream in [mergePeriods]. + */ + const val sparseBridgeActiveMaxMin: Int = 30 + + /** + * The same bound when HR across the whole span stays in the sleep band. + * + * [sparseBridgeActiveMaxMin]'s own doc calls the minute bound "the cheap half of the guard" and the HR + * band "the real one" — but the check order meant the cheap half vetoed first, so the real one never + * got to speak for a run over 30 minutes. A 42-minute active run with sleep-band HR was rejected + * identically to one with a wearer plainly up and about. + * + * That is the wrong way round on a strap whose "active" verdict comes from MOTION, which is the sparse + * and unreliable signal on the hardware this fires for (field log 260901-1022: 20,647 gravity samples + * across a 54-hour window, against 109,868 HR). HR is the better witness there, and it is already + * computed. 60 rather than something larger because it is [minSleepMin]: an interruption long enough + * to be a session in its own right is a genuine awakening whatever HR says, and must still split. + * + * Applied as a MAXIMUM against [sparseBridgeActiveMaxMin], never a replacement, so the in-band path + * can only ever be more permissive — an out-of-band span keeps the 30-minute bound exactly. + */ + const val sparseBridgeActiveMaxInBandMin: Int = 60 + // ── Stage 1–3 constants (sleep_features.py) ────────────────────────────── const val epochS: Double = 30.0 @@ -471,8 +525,288 @@ object SleepStager { if (baseline == null) return false val seg = hr.filter { it.ts > a && it.ts <= b } if (seg.isEmpty()) return false - val meanHR = seg.sumOf { it.bpm }.toDouble() / seg.size.toDouble() - return meanHR <= baseline * hrSleepBandMult + // MEDIAN, not mean (#1657). [confirmSleepWithHR] twelve lines below already documents why the + // mean is the wrong statistic here — "a real sleep night carries brief arousal / wake HR spikes + // (observed to ~190 bpm)" that drag it above the band — and uses the median for exactly that + // reason. This gate answers the same question over a SHORTER window, where a single spike + // dominates the mean even harder: a two-minute stir inside a fifteen-minute interval can put the + // mean out of band while the wearer was asleep for thirteen of those minutes. The median rejects + // a SUSTAINED elevation just as firmly, which is the discrimination this gate exists to make. + val medianHR = HrvAnalyzer.median(seg.map { it.bpm.toDouble() }) + return medianHR <= baseline * hrSleepBandMult + } + + /** + * Percentile of the window's bpm that anchors the HR-only sleep band. + * + * NOT [hrBaseline]. That is the window MEDIAN, and it is the right anchor where it is used — as a + * CONFIRMATION gate on a run gravity stillness already found, where being permissive is deliberate. + * As a PRIMARY threshold it is disqualified by arithmetic rather than by tuning: a median splits the + * samples in half by definition, so a band of `median * 1.05` admits strictly more than half of any + * window whatever the data. Measured on a realistic 24 h (16 h awake 74-96, 8 h night 59-70) it + * called 14.4 hours sleep against a truth of 8. + * + * A tenth percentile sits in the night's trough instead, which is what a sleep band should be + * anchored to, and cannot admit half the window however the day is shaped. + */ + const val hrOnlyAnchorPercentile: Double = 0.10 + + /** + * Multiplier above [hrOnlyAnchorPercentile] that still counts as asleep. + * + * Numerically equal to [hrSleepBandMult] today, and deliberately a SEPARATE constant: that one is a + * confirmation gate's tolerance and this one is a detector's, and a future change to either has no + * business silently moving the other. + * + * Chosen conservatively from a sweep over anchor x multiplier against windows with known truth, + * because the two failure directions are not symmetric. Over-detection puts a wrong Rest number on + * screen; under-detection leaves "No data", which is the state this feature is trying to improve on + * and therefore a safe place to fail. p10 x 1.05 measured 8.1 h against a truth of 8 on a field-shaped + * 24 h window, and under-reads a long multi-night window rather than over-reading it (8.7 h of 16 h + * across two nights in 54 h) — pinned in `SleepStagerHrOnlyAnchorTest`. + */ + const val hrOnlyBandMult: Double = 1.05 + + /** + * The [hrOnlyAnchorPercentile] of `hr` by bpm, or null when empty. Nearest-rank (no interpolation), so + * the value is always one the wearer actually recorded and the two platforms cannot disagree on a + * rounding rule. + */ + internal fun hrOnlyBaseline(hr: List): Double? = hrPercentile(hr, hrOnlyAnchorPercentile) + + /** + * The `p` percentile of `hr` by bpm, nearest-rank. Shared with [hrOnlyBaseline] so the spread the + * trace reports is measured by the SAME rule as the anchor it is meant to be judged against — a + * diagnostic computed a different way would invite exactly the wrong conclusion. + */ + internal fun hrPercentile(hr: List, p: Double): Double? = + percentileOfSorted(hr.map { it.bpm.toDouble() }.sorted(), p) + + /** + * The `p` percentile of an ALREADY-SORTED bpm list, nearest-rank. + * + * Split out because the caller needs three percentiles from the same window, and the obvious + * spelling sorts once per percentile. On a 5/MG day that is ~160k samples sorted three times, on + * every scored day, across the 21-day rescore window - a cost this file has been burned by before + * (#836, #841). One sort, three reads. + */ + internal fun percentileOfSorted(sorted: List, p: Double): Double? { + if (sorted.isEmpty()) return null + val idx = ((sorted.size - 1) * p).toInt().coerceIn(0, sorted.size - 1) + return sorted[idx] + } + + /** + * How many distinct [hrOnlyEpochS] buckets `sortedByTs` spans. + * + * A single pass rather than a set, because `hrS` is already sorted by timestamp so the bucket key + * is non-decreasing and a running comparison is enough. The obvious spelling + * (`mapTo(HashSet()) { it.ts / hrOnlyEpochS }`) boxes EVERY sample to end up with a few thousand + * distinct keys - ~160k boxed longs per scored day across the 21-day rescore, which is the exact + * allocation shape #707 names as the heap-churn source under the OOM. A diagnostic must not cost + * what it is measuring. + */ + internal fun distinctEpochs(sortedByTs: List): Int { + var count = 0 + var last = Long.MIN_VALUE + for (s in sortedByTs) { + val key = s.ts / hrOnlyEpochS + if (key != last) { count++; last = key } + } + return count + } + + /** Epoch for the HR-only spine, in seconds. */ + const val hrOnlyEpochS: Long = 60 + + /** + * Sleep/active runs built from HEART RATE ALONE, for a strap that streams HR but banks no motion. + * + * Stage 0 is normally a gravity-stillness spine (Cole-Kripke) that HR only CONFIRMS, via + * [hrSleepBandAcross] and `confirmSleepWithHR`. A WHOOP 5/MG that cannot bond never banks motion at + * all — `SET_CLOCK` rides a handshake it never completes — so `grav` is empty, there is no spine, and + * no quantity of HR can stage the night (#1801). This builds the spine from the one signal such a + * strap does provide. + * + * Deliberately the SAME rule the confirm path already trusts: per-epoch MEDIAN bpm against + * `baseline * `[hrSleepBandMult], median for the reason [hrSleepBandAcross] spells out. The run + * construction follows [buildRuns] — close on a class change or a gap over [maxGapMin] — so the two + * spines segment alike once flags exist, and only the flag SOURCE differs. + * + * With ONE branch deliberately absent, and it is not an oversight. [buildRuns] can forgive a gap when + * [hrSleepBandAcross] vouches that HR stayed in band across it, which rescues a night whose GRAVITY + * dropped out. Here the gap IS in the heart rate, so there is nothing left to vouch with and no + * analogue to port. A long HR dropout therefore breaks an HR-only run where it would not break a + * motion-backed one, and a night fragmented that way is dropped by the caller's minimum-duration + * gate rather than bridged. + * + * WEAKER THAN THE MOTION SPINE, by construction rather than by tuning. The file already notes that a + * long still daytime stretch is gravity-indistinguishable from a nap and that HR is what saves it; + * with motion gone the inverse is exposed, and a quiet evening at rest can sit in the sleep band. A + * caller must treat these runs as lower-confidence than a motion-backed night and must not let one + * reach a baseline it cannot be unwound from. + * + * Bucket order does not depend on sort stability: samples are grouped by epoch and reduced with a + * median, so their order within an epoch cannot change the result. + */ + internal fun hrOnlySleepRuns( + hr: List, + baseline: Double?, + epochS: Long = hrOnlyEpochS, + maxGapMinutes: Int = maxGapMin, + ): List { + if (baseline == null || baseline <= 0.0) return emptyList() + if (hr.isEmpty() || epochS <= 0L) return emptyList() + val byEpoch = HashMap>() + val lastTs = HashMap() + for (s in hr) { + val k = s.ts / epochS + byEpoch.getOrPut(k) { ArrayList() }.add(s.bpm.toDouble()) + lastTs[k] = maxOf(lastTs[k] ?: Long.MIN_VALUE, s.ts) + } + val keys = byEpoch.keys.sorted() + // Two axes, deliberately. A gap and a run's START use the epoch's own start, so a gap is measured + // between epochs rather than between whichever samples sat at their edges. A run's END is the last + // SAMPLE observed in its final epoch, which is what [buildRuns] means by `end` — reading the epoch + // start there would report every run one whole epoch shorter than the data it covers, and that + // understatement would then be weighed against the caller's minimum-duration gate. + val times = keys.map { it * epochS } + val ends = keys.map { lastTs.getValue(it) } + val flags = keys.map { HrvAnalyzer.median(byEpoch.getValue(it)) <= baseline * hrOnlyBandMult } + val maxGapS = (maxGapMinutes * 60).toLong() + val periods = ArrayList() + var runStart = 0 + for (i in 1..keys.size) { + val atEnd = (i == keys.size) + val close = if (atEnd) { + true + } else { + flags[i] != flags[runStart] || (times[i] - times[i - 1]) > maxGapS + } + if (close) { + periods.add( + Period( + stage = if (flags[runStart]) "sleep" else "active", + start = times[runStart], + end = ends[i - 1], + ) + ) + runStart = i + } + } + return periods + } + + /** + * Whole sleep SESSIONS from heart rate alone, for a strap that banks no motion (#1801). + * + * [hrOnlySleepRuns] supplies the spine this normally gets from gravity stillness; [SleepStagerV2] + * then stages each surviving run from HR and R-R with an EMPTY gravity list. That is not a + * degenerate call: V2's epoch features read HR and R-R directly and only its motion-quiescence terms + * go quiet, so it returns a real hypnogram rather than one flat stage. A stageless session would be + * dropped by `AnalyticsEngine.sleepSessionFromProvided` anyway, so a night that fails to stage is + * correctly omitted here rather than passed on hollow. + * + * The anchor is [hrOnlyBaseline] — the [hrOnlyAnchorPercentile] of the window — and NOT the + * [hrBaseline] median the motion path derives. Reusing that median looked like parity and was a bug: + * as a confirmation gate on an already-detected run it is deliberately permissive, but as a primary + * threshold it admits over half of any window by definition. See [hrOnlyAnchorPercentile]. + * + * [DetectedSleep.restingHR] and [DetectedSleep.avgHRV] are MEASURED here and reported (#1884). They + * were withheld under #1801, which treated them as inferred; they are not. Only the session BOUNDS + * are inferred from heart rate — that is what [DetectedSleep.hrOnly] marks — while each RMSSD is + * computed over its own 5-minute window, so fuzzy bounds change WHICH windows are included, not + * whether any one of them is valid. Withholding them discarded a measured 22-25 ms and left Charge + * with no input at all, which is a worse error than a slightly fuzzy one. + * + * The flag travels on instead, so a consumer that wants to weigh an HR-only night down still can. + */ + internal fun hrOnlySessions( + hr: List, + rr: List, + resp: List, + minMinutes: Int = minSleepMin, + /** + * #1801 follow-up: this path shipped SILENT, and the first field log then showed + * `reason=no-motion` with no way to tell whether the spine ran and found nothing or never ran. + * Everything it decides is derived from the wearer's own window, so a report is unreadable + * without it. Optional, so pure-function callers and tests stay free of a sink. + */ + traceSink: ((String) -> Unit)? = null, + ): List { + val hrS = hr.sortedBy { it.ts } + // ONE sort of the bpm axis, reused for the anchor and for the spread the trace reports. + val sortedBpm = hrS.map { it.bpm.toDouble() }.sorted() + val baseline = percentileOfSorted(sortedBpm, hrOnlyAnchorPercentile) + if (baseline == null) { + traceSink?.invoke(SleepStagerTrace.hrOnlyLine( + anchorBpm = null, bandBpm = null, hrP50 = null, hrP90 = null, epochs = 0, runs = 0, mergedRuns = 0, + sleepRuns = 0, longestSleepMin = 0, staged = 0, kept = 0, minSleepMin = minMinutes, + )) + return emptyList() + } + val rrS = rr.sortedBy { it.ts } + val out = ArrayList() + // mergePeriods for the same reason the motion path calls it: a run boundary is a threshold + // crossing, and a sleeping heart rate oscillates across the band all night. Without this the + // spine returns the night's minutes correctly but shredded into sub-mergeMin fragments, every one + // of which then fails the minimum-duration gate below — 8 h of detected sleep yielding zero + // sessions. Absorbing the short runs first is what turns a spine into a night. + val rawRuns = hrOnlySleepRuns(hrS, baseline) + val merged = mergePeriods(rawRuns) + var staged = 0 + var longestSleepS = 0L + for (p in merged) { + if (p.stage != "sleep") continue + longestSleepS = maxOf(longestSleepS, p.end - p.start) + if ((p.end - p.start) < minMinutes * 60L) continue + val stages = SleepStagerV2.stageSession(p.start, p.end, emptyList(), hrS, rrS, resp) + staged++ + if (stages.isEmpty()) continue + out.add( + DetectedSleep( + start = p.start, + end = p.end, + efficiency = efficiency(start = p.start, end = p.end, stages = stages), + stages = stages, + // #1884: MEASURED, not nulled. The bounds here are inferred from heart rate, which is + // why `hrOnly` marks the session — but each RMSSD is computed over its own 5-minute + // window tagged by the stage at its centre, so fuzzy bounds change WHICH windows are + // included, not whether any one of them is valid. Field logs showed this path + // computing wholeNight=24.04ms / deepOnly=22.44ms over 55 windows and then throwing it + // away, which left Charge with `nilScore reason=missingInput` on every pass. Resting HR + // is HR-derived and an HR-only night is precisely the night with plenty of HR. + // + // `hrOnly` still travels with the session and is persisted as `dailyMetric.sleepHrOnly` + // (#1879), so it is a quality marker now rather than a delete. Its one reader today, + // `TodayScreen.showsHrOnlyNote`, is gated on a vital ACTUALLY being blank, so the note + // explaining the blanks retires itself on the nights this change fills in. + restingHR = sessionRestingHR(start = p.start, end = p.end, hr = hrS), + avgHRV = sessionAvgHRV(start = p.start, end = p.end, rr = rrS), + hrOnly = true, + ) + ) + } + traceSink?.invoke(SleepStagerTrace.hrOnlyLine( + anchorBpm = baseline, + bandBpm = baseline * hrOnlyBandMult, + // The wearer's own spread. An anchor alone cannot be judged: p10 of 60 means one thing when + // the median is 63 and quite another when it is 74, and only the second leaves a night the + // band can separate. + hrP50 = percentileOfSorted(sortedBpm, 0.50), + hrP90 = percentileOfSorted(sortedBpm, 0.90), + // The real epoch count, not the sample count: the spine buckets by [hrOnlyEpochS] before it + // decides anything, so this is the axis every other number here is measured on. + epochs = distinctEpochs(hrS), + runs = rawRuns.size, + mergedRuns = merged.size, + sleepRuns = merged.count { it.stage == "sleep" }, + longestSleepMin = (longestSleepS / 60L).toInt(), + staged = staged, + kept = out.size, + minSleepMin = minMinutes, + )) + return out } /** Per-record sleep flags from a rolling fraction of "still" samples. */ @@ -594,31 +928,125 @@ object SleepStager { } /** - * Sparse-gravity bridge (#308): merge two adjacent SLEEP runs separated ONLY by a gap up to - * sparseBridgeGapMin minutes when the intervening HR stays in the sleep band — so a real night - * fragmented by gravity dropouts is re-stitched into one continuous in-bed span BEFORE the - * minSleepMin gate drops the pieces. Active runs and over-threshold gaps are left untouched; the - * span between two bridged sleep runs (an "active"/gap run, if present) is absorbed. A no-op when - * [sparse] == false, so the dense 4.0 path is unchanged. Mirrors Swift. + * Sparse-gravity bridge (#308): merge two SLEEP runs when the intervening HR stays in the sleep + * band — so a real night fragmented by gravity dropouts is re-stitched into one continuous in-bed + * span BEFORE the minSleepMin gate drops the pieces. A no-op when [sparse] == false, so the dense + * 4.0 path is unchanged. Mirrors Swift. + * + * What sits between the two runs may be a bare gap (up to [sparseBridgeGapMin]) or ONE active run + * (additionally up to [sparseBridgeActiveMaxMin]). Over-threshold gaps, longer active runs and two + * consecutive active runs are all left untouched. + * + * The previous wording here — "Active runs … are left untouched; the span between two bridged sleep + * runs (an "active"/gap run, if present) is absorbed" — read as though an intervening active run was + * already handled. It was not: only a bare gap was, and that sentence is a large part of why #1657 + * went unnoticed. Kept in the history rather than quietly deleted, because the next person to widen + * this function will read this comment first. */ internal fun bridgeSparseSleep( periods: List, sparse: Boolean, hr: List, baseline: Double?, - ): List { - if (!sparse || periods.isEmpty()) return periods + ): List = bridgeSparseSleepTraced(periods, sparse, hr, baseline).first + + /** + * One pair the bridge looked at, for the Sleep & Rest trace. Kept beside the merge itself rather + * than recomputed by a shadow pass: a second implementation of this loop would have to be edited in + * step with the first, and a trace that silently disagrees with the behaviour it describes is worse + * than no trace. Mirrors Swift `SparseBridgeAttempt`. + */ + internal data class SparseBridgeAttempt( + /** Gap between the two sleep runs, whole minutes (negative when they overlap). */ + val gapMin: Long, + /** Minutes of intervening ACTIVE run, or 0 when the pair was only separated by a gap. */ + val activeMin: Long, + /** The bound this pair was actually judged against — 30, or 60 when HR stayed in the sleep band. */ + val activeCapMin: Long, + /** Whether the intervening HR stayed in the sleep band. */ + val hrInSleepBand: Boolean, + /** Whether this pair was merged. */ + val bridged: Boolean, + /** Stable token: bridged / gapTooLong / hrOutOfBand / overlap / activeTooLong. */ + val reason: String, + ) + + /** + * The bridge, plus what it considered. See [bridgeSparseSleep] for the merge rule. + * + * #1657: an intervening ACTIVE run no longer blocks the merge permanently. The original loop could + * only join runs already adjacent in its own output, so any active run between two sleep runs was + * appended first and made the next pair unreachable — and a field trace found the bridge merging + * NOTHING on 14 of 14 sparse nights for exactly that reason. Since a bathroom trip is definitionally + * an active run, the rescue built for fragmentation was unavailable in the case that needs it most. + * + * A single active run up to [sparseBridgeActiveMaxMin] is now absorbed, with the whole span still + * subject to [sparseBridgeGapMin] and to the HR band. Two or more consecutive active runs are not: + * that is a night with real structure in it, not one interruption. + */ + internal fun bridgeSparseSleepTraced( + periods: List, sparse: Boolean, hr: List, baseline: Double?, + ): Pair, List> { + if (!sparse || periods.isEmpty()) return periods to emptyList() val bridgeGapS = (sparseBridgeGapMin * 60).toLong() + val activeMaxS = (sparseBridgeActiveMaxMin * 60).toLong() + val activeMaxInBandS = (sparseBridgeActiveMaxInBandMin * 60).toLong() val out = ArrayList() + val attempts = ArrayList() for (p in periods) { - val last = out.lastOrNull() - if (last != null && last.stage == "sleep" && p.stage == "sleep") { - val gap = p.start - last.end - if (gap in 0..bridgeGapS && hrSleepBandAcross(last.end, p.start, hr, baseline)) { - out[out.size - 1] = Period(stage = "sleep", start = last.start, end = p.end) - continue + if (p.stage == "sleep") { + val last = out.lastOrNull() + // Case 1: the previous run is sleep — the original adjacent-pair merge. + if (last != null && last.stage == "sleep") { + if (considerBridge(out, attempts, last, p, activeS = 0L, bridgeGapS, hr, baseline)) continue + } + // Case 2: exactly one active run sits between two sleep runs. Absorbed when short + // enough, which is the #1657 case the original loop could never reach. + val prev = if (out.size >= 2) out[out.size - 2] else null + if (last != null && last.stage == "active" && prev != null && prev.stage == "sleep") { + val activeS = last.end - last.start + if (considerBridge(out, attempts, prev, p, activeS, bridgeGapS, hr, baseline, + dropTrailing = true, activeMaxS = activeMaxS, + activeMaxInBandS = activeMaxInBandS)) continue } } out.add(p) } - return out + return out to attempts + } + + /** + * Judge one candidate pair, record it, and merge when it passes. Returns true when [p] was absorbed. + * + * [dropTrailing] removes the intervening active run from [out] before merging — the whole point of + * case 2. The order of the checks fixes which reason a failing pair reports, and it is deliberate: + * overlap and gap are properties of the pair, activeTooLong is a property of what sits between them, + * and hrOutOfBand is last because it is the only one that needed the HR series to decide. + */ + private fun considerBridge( + out: ArrayList, attempts: ArrayList, + left: Period, right: Period, activeS: Long, bridgeGapS: Long, + hr: List, baseline: Double?, + dropTrailing: Boolean = false, activeMaxS: Long = 0L, activeMaxInBandS: Long = 0L, + ): Boolean { + val gap = right.start - left.end + val inBand = hrSleepBandAcross(left.end, right.start, hr, baseline) + // The HR band is the real guard, so let it widen the minute bound rather than be vetoed by it. + // maxOf, not a swap: in-band can only ever be MORE permissive, and case 1 (activeMaxS = 0, no + // intervening run) is untouched because activeS is 0 there too. + val activeCapS = if (inBand) maxOf(activeMaxS, activeMaxInBandS) else activeMaxS + val reason = when { + gap < 0 -> "overlap" + gap > bridgeGapS -> "gapTooLong" + activeS > activeCapS -> "activeTooLong" + !inBand -> "hrOutOfBand" + else -> "bridged" + } + val bridged = reason == "bridged" + attempts.add(SparseBridgeAttempt(gapMin = gap / 60, activeMin = activeS / 60, + activeCapMin = activeCapS / 60, + hrInSleepBand = inBand, bridged = bridged, reason = reason)) + if (!bridged) return false + if (dropTrailing) out.removeAt(out.size - 1) + out[out.size - 1] = Period(stage = "sleep", start = left.start, end = right.end) + return true } // ── HR refinement ──────────────────────────────────────────────────────── @@ -1148,7 +1576,8 @@ object SleepStager { runs = mergePeriods(runs) // Re-stitch sleep runs fragmented by pure gravity dropouts (sparse only) before minSleepMin. val runsBeforeBridge = if (traceSink == null) 0 else runs.count { it.stage == "sleep" } - runs = bridgeSparseSleep(runs, sparse = sparse, hr = hrS, baseline = baseline) + val bridged = bridgeSparseSleepTraced(runs, sparse = sparse, hr = hrS, baseline = baseline) + runs = bridged.first // Sleep & Rest test mode (E10): record the sparse-gravity bridge result, so a sparse 5.0 night // rescued from fragmentation is visible. Only when gravity is sparse and only when tracing. if (traceSink != null && sparse) { @@ -1156,6 +1585,24 @@ object SleepStager { traceSink(SleepStagerTrace.runLine(-1, 0, 0, if (runsAfterBridge < runsBeforeBridge) SleepStagerTrace.Verdict.KEPT else SleepStagerTrace.Verdict.DROPPED, "sparseBridge", "sparse=true gapMin=$sparseBridgeGapMin runsBefore=$runsBeforeBridge runsAfter=$runsAfterBridge")) + // #1657: one line per pair the bridge CONSIDERED, ported from Swift and extended with the + // intervening active run's length. No lines at all still means no candidate pair existed; + // an activeTooLong line names the bound that blocked one, which is the number a future + // reader needs and the old trace could not supply. Android had NONE of this before — the + // line that made #1657 diagnosable was iOS-only, so an Android reporter saw runsBefore == + // runsAfter and no reason at all. + if (bridged.second.isEmpty()) { + traceSink(SleepStagerTrace.runLine(-1, 0, 0, SleepStagerTrace.Verdict.DROPPED, + "sparseBridge", + "no candidate pairs (one sleep run, or fragments split by consecutive active runs)")) + } + for ((i, a) in bridged.second.withIndex()) { + traceSink(SleepStagerTrace.runLine(-1, 0, 0, + if (a.bridged) SleepStagerTrace.Verdict.KEPT else SleepStagerTrace.Verdict.DROPPED, + "sparseBridgePair", + "pair=$i gapMin=${a.gapMin} activeMin=${a.activeMin} activeCapMin=${a.activeCapMin} " + + "hrInSleepBand=${a.hrInSleepBand} reason=${a.reason}")) + } } val minSleepS = (minSleepMin * 60).toLong() @@ -2644,20 +3091,119 @@ object SleepStager { // ── Per-session HR / HRV ───────────────────────────────────────────────── - /** Lowest 5-min rolling-mean HR during the session (bpm), or null. */ + /** + * #1943: a conformance check that reports when the artefact gate `sessionRestingHR` applies + * actually MOVED the floor. The shipped floor IS the gated floor, so comparing the gated floor + * against it is silent by construction. Instead, this reports the UNGATED floor (the old rule: + * min over every non-empty bin) against the shipped one, so the line fires when the gate + * excluded the bin that would otherwise have won — which is exactly the frequency and magnitude + * the measure-only diagnostic was meant to learn, and would never have reported otherwise. + * + * [SleepStager.sessionRestingHR] gates bins on [rhrMinBinSamples] and [rhrMinPlausibleBpm] before + * letting them win the floor, falling back to the lowest of all bin means when no bin qualifies. + * This helper reproduces the same partition and the same gate, and ALSO computes the ungated floor + * (min over every non-empty bin, the pre-#1943 rule) so the two can be compared. + * + * Bins are built exactly as `sessionRestingHR` builds them, closed final bin included, or the line + * would describe a different partition than the one it is judging. + * + * Returns null unless the gate actually MOVED the floor (ungated != shipped), so the log carries + * only the nights the gate did something. If it turns out to fire on one night in fifty, that is + * worth knowing; if it fires nightly, that is worth knowing sooner. + * + * The counts still ride along when the line does fire, since they are the context for the change. + * Same posture as the over-count-only R-R dump. Counts and bpm only, no timestamps. Pure. Twin of + * Swift `rhrBinGateLogLine`. + */ + internal fun rhrBinGateLogLine( + day: String, + sessions: List>, + hr: List, + shippedFloor: Int, + minBinSamples: Int = rhrMinBinSamples, + minPlausibleBpm: Double = rhrMinPlausibleBpm, + ): String? { + val windowS = 5 * 60L + var bins = 0 + var thin = 0 + var implausible = 0 + var ungated: Double? = null // the pre-#1943 rule: min over every non-empty bin + var ungatedN = 0 + var gated: Double? = null // the current rule: min over qualifying bins only + for ((start, end) in sessions) { + val seg = hr.filter { it.ts in start..end } + if (seg.isEmpty()) continue + var t = start + do { + val isFinal = t + windowS >= end + val win = seg.filter { it.ts >= t && (isFinal || it.ts < t + windowS) } + if (win.isNotEmpty()) { + bins += 1 + val mean = win.sumOf { it.bpm }.toDouble() / win.size.toDouble() + if (win.size < minBinSamples) thin += 1 + if (mean < minPlausibleBpm) implausible += 1 + if (ungated == null || mean < ungated!!) { ungated = mean; ungatedN = win.size } + if (win.size >= minBinSamples && mean >= minPlausibleBpm && + (gated == null || mean < gated!!) + ) gated = mean + } + t += windowS + } while (t < end) + } + if (bins == 0) return null + // `roundToInt`, matching sessionRestingHR: these numbers are compared against that function's + // output, so the two must not round a tie differently. + val ungatedFloor = ungated?.roundToInt() + // The gate moved the floor when the ungated floor differs from the shipped one. The shipped + // floor IS the gated floor, so this fires when the gate excluded the bin that would have won + // under the old rule — which is the frequency and magnitude we want to learn. + val moved = ungatedFloor != null && ungatedFloor != shippedFloor + if (!moved) return null + val gatedFloor = gated?.roundToInt() + return "rhr bins day=$day bins=$bins thin=$thin implausible=$implausible " + + "winnerN=$ungatedN ungated=${ungatedFloor ?: "nil"} gated=${gatedFloor ?: "nil"} " + + "shipped=$shippedFloor gateMoved=$moved" + } + + /** + * Lowest 5-min rolling-mean HR during the session (bpm), or null. + * + * The window is CLOSED at both ends, so the binning is too: bins are `[t, t + windowS)` except + * the final one, which is `[t, end]`. Half-open bins alone would admit a sample sitting exactly + * on an aligned `end` through the prefilter and then place it in no bin — counted as data, + * silently ignored. A zero-length window (`start == end`) is that single closed bin. + */ internal fun sessionRestingHR(start: Long, end: Long, hr: List): Int? { val seg = hr.filter { it.ts in start..end } if (seg.isEmpty()) return null val windowS = 5 * 60L - val means = ArrayList() + // #1943: a bin qualifies to WIN the floor only when it is well-populated (≥ rhrMinBinSamples) + // and its mean is physiologically plausible (≥ rhrMinPlausibleBpm). A one-sample bin at the + // edge of a wear gap, or a dropout-driven sub-physiological dip, cannot become the night's + // resting HR — that number is displayed, stored on the daily row, and fed to the baseline + // later nights are scored against. If no bin qualifies, fall back to the lowest of ALL bin + // means (ungated), then the all-sample mean — preserving the never-null-on-data behaviour. + val gatedMeans = ArrayList() + val allMeans = ArrayList() var t = start - while (t < end) { - val win = seg.filter { it.ts >= t && it.ts < t + windowS } - if (win.isNotEmpty()) means.add(win.sumOf { it.bpm }.toDouble() / win.size.toDouble()) + do { + // The last bin (its half-open end reaches or passes `end`) closes on `end` instead, + // catching an endpoint sample the prefilter already admitted. `seg` holds nothing past + // `end`, so "everything from t onwards" IS [t, end]. `do` runs once for a zero-length + // window, where that single closed bin is the whole window. + val isFinal = t + windowS >= end + val win = seg.filter { it.ts >= t && (isFinal || it.ts < t + windowS) } + if (win.isNotEmpty()) { + val mean = win.sumOf { it.bpm }.toDouble() / win.size.toDouble() + allMeans.add(mean) + if (win.size >= rhrMinBinSamples && mean >= rhrMinPlausibleBpm) gatedMeans.add(mean) + } t += windowS - } - val m = means.minOrNull() + } while (t < end) + val m = gatedMeans.minOrNull() if (m != null) return m.roundToInt() + val am = allMeans.minOrNull() + if (am != null) return am.roundToInt() val all = seg.sumOf { it.bpm }.toDouble() / seg.size.toDouble() return all.roundToInt() } @@ -2673,13 +3219,43 @@ object SleepStager { */ internal fun sessionAvgHRV(start: Long, end: Long, rr: List): Double? { val vals = sessionHrvWindows(start, end, rr, emptyList()).mapNotNull { it.rmssd } - return if (vals.isEmpty()) null else vals.sum() / vals.size.toDouble() + if (vals.isEmpty()) return null + // #1118: refuse the night outright when its own R-R banks more beat-time than the wall clock it + // spans. Gated HERE rather than at the caller because this is where RMSSD BECOMES the day's HRV: + // one seam covers the daily row, the sleep-session cache, the Health card and the baseline that + // later nights are scored against, so none of them can end up disagreeing about whether the night + // was trustworthy. See [HrvAnalyzer.successiveDiffIsTrustworthy] for why an over-count corrupts a + // successive-difference statistic and why a blank is the right answer. + // + // Classified over the SAME beats the value was built from, windowed [start, end] exactly as + // `sessionHrvWindows` does, so the verdict cannot describe a different set of beats than the number + // it is gating. + val seg = rr.filter { it.ts in start..end } + val segTs = seg.map { it.ts } + val segMs = seg.map { it.rrMs.toDouble() } + val coverage = HrvAnalyzer.rrCoverage(segTs, segMs) + // `collapsed` is deliberately the SAME figure as `coverage`, which pins every over-count here to + // CROSS_SECOND. That is not a claim about which kind it is. The collapsed figure exists only to + // choose BETWEEN the two over-count verdicts, and this gate refuses both, so the real one would + // change no outcome — while costing a full sort of the night's ~50-70k beats, since + // [HrvAnalyzer.collapsedCoverage] opens with `sortedWith`. This runs per session, per day, across + // ~21 days of every analyzeRecent, every 15 minutes; #1510 cut this exact path from six sorts a + // night to two, and buying a distinction the caller discards would hand that back. `rrCoverage` is + // a single O(n) pass. If a future gate ever needs the two over-count cases apart, compute it then. + val verdict = HrvAnalyzer.classifyCoverage(coverage, coverage) + if (!HrvAnalyzer.successiveDiffIsTrustworthy(verdict)) return null + return vals.sum() / vals.size.toDouble() } /** * Per-5-min-window RMSSD across a session, each window tagged with the sleep stage at its CENTER (from * [stages]) — the SINGLE source [sessionAvgHRV] averages, and the HRV test-mode nightly trace reads. * Passing `emptyList()` for [stages] tags every window "?" (the plain-average path doesn't need stages). + * + * Windows follow the same closed-window rule as [sessionRestingHR]: `[t, t + windowS)` except the final + * one, which is `[t, end]`, so a beat sitting exactly on an aligned `end` lands in a window instead of + * being admitted by the prefilter and then dropped. Window stage tagging and [HrvWindow.startTs] are + * unchanged — the final window keeps its half-open center `t + windowS / 2`. */ internal fun sessionHrvWindows( start: Long, end: Long, rr: List, stages: List, @@ -2693,8 +3269,12 @@ object SleepStager { val windowS = 5 * 60L val out = ArrayList() var t = start - while (t < end) { - val bucket = seg.filter { it.ts >= t && it.ts < t + windowS }.map { it.rrMs.toDouble() } + do { + // Final window closes on `end` — same closed-window rule as sessionRestingHR, so an + // endpoint beat counts instead of vanishing after admission. `do` runs once for a + // zero-length window, where that single closed window is the whole session. + val isFinal = t + windowS >= end + val bucket = seg.filter { it.ts >= t && (isFinal || it.ts < t + windowS) }.map { it.rrMs.toDouble() } // Full clean (range + Malik ectopic rejection), not just range — matches the // analyze() pipeline. The 0x2A37 RR on a WHOOP 5/MG is PPG-derived and noisier // than a 4.0's; rMSSD is built from SUCCESSIVE differences, so an un-rejected @@ -2708,7 +3288,7 @@ object SleepStager { val stage = stages.firstOrNull { center >= it.start && center < it.end }?.stage ?: "?" out.add(HrvWindow(startTs = t, stage = stage, cleanBeats = cleaned.nn.size, rmssd = rmssd)) t += windowS - } + } while (t < end) return out } diff --git a/android/app/src/main/java/com/noop/analytics/SleepStagerTrace.kt b/android/app/src/main/java/com/noop/analytics/SleepStagerTrace.kt index af8f0a9f52..dd1cff7e6d 100644 --- a/android/app/src/main/java/com/noop/analytics/SleepStagerTrace.kt +++ b/android/app/src/main/java/com/noop/analytics/SleepStagerTrace.kt @@ -7,6 +7,62 @@ package com.noop.analytics object SleepStagerTrace { enum class Verdict(val tag: String) { KEPT("KEPT"), DROPPED("DROPPED") } + /** + * Why the HR-only spine did or did not run (#1801). + * + * The funnel line below only exists when the spine is CALLED, so its absence was ambiguous in + * exactly the way that made the first field report unreadable: it could mean the gate never + * opened, or that the sink was never wired. This says which, on any day with no motion — the only + * days where the question arises. A day that HAS motion never prints it, because the motion spine + * is the answer there and nothing was skipped. + */ + fun hrOnlyGateLine(attempted: Boolean, reason: String, gravRows: Int, storedNights: Int): String = + "[sleep] hr-only gate attempted=$attempted reason=$reason grav=$gravRows stored=$storedNights" + + /** + * The HR-only spine's own funnel line (#1801). + * + * The path shipped silent, and a field log then showed `reason=no-motion` with no way to tell + * whether the spine had run and found nothing or never ran at all — in a file whose entire sleep + * story is a funnel. Every number here exists to separate the two failures that actually happen: + * a band too tight (`sleepRuns` near zero) from a duration gate eating real runs (`sleepRuns` high, + * `longestMin` under `minSleepMin`). + * + * `anchorBpm` and `bandBpm` are printed because they are derived, not configured: the anchor is a + * percentile of THIS window, so the same code gives a different threshold to every wearer, and a + * complaint is unreadable without knowing which one they got. + */ + fun hrOnlyLine( + anchorBpm: Double?, bandBpm: Double?, hrP50: Double?, hrP90: Double?, epochs: Int, + runs: Int, mergedRuns: Int, sleepRuns: Int, + longestSleepMin: Int, staged: Int, kept: Int, minSleepMin: Int, + ): String = + "[sleep] hr-only spine anchorBpm=${round1(anchorBpm)} bandBpm=${round1(bandBpm)} " + + "hrP50=${round1(hrP50)} hrP90=${round1(hrP90)} " + + "epochs=$epochs runs=$runs merged=$mergedRuns sleepRuns=$sleepRuns " + + "longestMin=$longestSleepMin staged=$staged kept=$kept minSleepMin=$minSleepMin" + + /** + * One decimal, by ARITHMETIC rather than `printf`. + * + * `String.format("%.1f", …)` is not a twin: Java rounds HALF_UP on the decimal expansion while + * Swift's `String(format:)` goes through C `printf`, which rounds half-to-even on the actual + * binary value. A harness caught them disagreeing on 64.05 — Kotlin 64.1, Swift 64.0 — and a band + * of `anchor * 1.05` produces trailing decimals constantly, so that would have diverged on most + * real values. Multiplying and rounding is IEEE-deterministic, so both platforms do the same + * arithmetic to the same bits and cannot disagree, whichever side of .5 the product lands on. + * Same reasoning as [round2] one line down, which was already arithmetic. + * + * NON-NEGATIVE ONLY. `-0.5` would print "0.5", because the integer division truncates toward zero + * and the sign is then lost to `abs`. Every current caller is a bpm, which cannot be negative; + * a caller that could be would need the sign carried separately. + */ + private fun round1(v: Double?): String { + if (v == null) return "nil" + val tenths = Math.round(v * 10.0) + return "${tenths / 10}.${Math.abs(tenths % 10)}" + } + fun runLine(index: Int, startTs: Long, endTs: Long, verdict: Verdict, gate: String, detail: String): String { val spanS = maxOf(0L, endTs - startTs) return "gate run=$index spanS=$spanS ${verdict.tag} gate=$gate $detail" diff --git a/android/app/src/main/java/com/noop/analytics/SlidingStreamWindow.kt b/android/app/src/main/java/com/noop/analytics/SlidingStreamWindow.kt new file mode 100644 index 0000000000..197d726704 --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/SlidingStreamWindow.kt @@ -0,0 +1,143 @@ +package com.noop.analytics + +/** + * One stream's backward sliding read buffer for `analyzeRecent`'s pass-1 loop (#1538). + * + * [WindowedStreamPlan] decides WHETHER a day's window can reuse what the previous day read; this holds + * the rows and does the splice. Kept out of the engine because the splice is the part that can silently + * be wrong — a row dropped here is a scoring input that vanishes — so it gets its own tests rather than + * living inline in a 1500-line loop. Twin of Swift `SlidingStreamWindow`. + * + * The buffer always ends up covering EXACTLY the window just served, never more: the walk runs backwards + * so the tail above `to` is never asked for again, and holding it would grow the footprint across the + * pass instead of keeping it at one window. Rows are stored in the store's own `ts ASC` order and an + * extension is always strictly BELOW the buffer, so prepending preserves that order without a sort. + * + * @param tsOf the row's timestamp, in the same unit the window bounds use. + * @param limit the store's row cap. A read returning [limit] rows was truncated: the queries are + * `ORDER BY ts ASC LIMIT`, so truncation silently drops the NEWEST rows, and a buffer built from one + * would be missing its tail with nothing to indicate it. + */ +class SlidingStreamWindow( + private val tsOf: (T) -> Long, + private val limit: Int, + /** + * The store call for `(owner, from, to)`. Bound at CONSTRUCTION rather than passed per call: the two + * call sites live in `analyzeRecentOnCpu`, which sits close enough to the JVM's 64 KB bytecode ceiling + * to have a budget test of its own, and a lambda at each call site put it over. It must return + * `ts ASC` and honour the same [limit]. + * + * NULL means the read FAILED, which is not the same as returning no rows and must never be cached as + * if it were. An empty successful read is a true statement about a range — the buffer can serve it, + * and the next day may splice against it. An empty FAILED read is no statement at all, and caching it + * would let the following day splice against rows nobody ever fetched, silently dropping the part of + * its window the buffer claimed to hold. This side never returns null today (a repo read throws, and + * the throw propagates exactly as it did before the buffer existed); the Swift twin does, because its + * engine wraps reads in `try?` and turns a failure into an empty array. + */ + private val read: suspend (String, Long, Long) -> List?, +) { + + private var owner: String? = null + private var from = 0L + private var to = 0L + private var truncated = false + private var rows: List = emptyList() + + /** Rows this window served from the buffer rather than reading. Diagnostic only. + * + * `Long` here, `Int` on the Swift twin, and that is deliberate rather than drift: each side follows + * its own platform's convention for a count, and the value cannot reach either limit — a pass is + * bounded by `maxDays` windows of at most [limit] rows, so about 4 M on a 21-day pass. Stated because + * a width left to be inferred is how the 32-bit `pct` over-count got in (#1685). */ + var rowsServed = 0L + private set + + /** Rows this window read from the store. Diagnostic only. Same width note as [rowsServed]. */ + var rowsRead = 0L + private set + + /** + * Reads whose RESULT came back at the store's cap, so the newest rows were dropped and the day was + * scored on an incomplete window. Diagnostic only, but unlike its siblings this one is a correctness + * signal rather than a savings one: a non-zero count means a number may be wrong, not merely slow. + * + * Counted per WINDOW that lost rows, not per pass: once a read is truncated the planner refuses to + * splice at all, so each later window is a fresh full read and is judged on its own. The truncated- + * EXTENSION branch below cannot add a second count for one window either, since it only ever re-reads + * a SUPERSET of what already overran the cap. Pinned by `eachTruncatedWindowCountsSeparately`. + */ + var truncatedReads = 0L + private set + + /** + * The rows for `[from, to]` under [owner], reading as little as the plan allows. + * + * The result is byte-for-byte what a direct `read(owner, from, to)` would have returned — that is the + * whole contract, and the reason every case the planner cannot prove falls back to exactly that call. + */ + suspend fun rows(owner: String, from: Long, to: Long): List { + val plan = WindowedStreamPlan.plan(this.owner, this.from, this.to, truncated, owner, from, to) + val result: List + val nowTruncated: Boolean + when (plan) { + is WindowedStreamPlan.Plan.Serve -> { + result = rows.filter { tsOf(it) in from..to } + nowTruncated = false + rowsServed += result.size + } + is WindowedStreamPlan.Plan.Extend -> { + val head = read(owner, plan.readFrom, plan.readTo) ?: return failedRead() + rowsRead += head.size + if (head.size >= limit) { + // A truncated EXTENSION is worse than a truncated buffer: it would leave a hole in the + // middle rather than at the end. Discard and read the window whole. + val full = read(owner, from, to) ?: return failedRead() + rowsRead += full.size + result = full + nowTruncated = full.size >= limit + } else { + // Filtered into ONE list rather than `(head + rows).filter { }`: that form allocates + // the concatenation AND the filtered copy, so the splice transiently held roughly + // three windows' worth of references on a phone during the cold pass. Same result, + // one allocation. + val out = ArrayList(head.size + rows.size) + for (r in head) if (tsOf(r) in from..to) out.add(r) + for (r in rows) if (tsOf(r) in from..to) out.add(r) + result = out + nowTruncated = false + rowsServed += (result.size - head.size).coerceAtLeast(0) + } + } + is WindowedStreamPlan.Plan.FullRead -> { + val full = read(owner, from, to) ?: return failedRead() + rowsRead += full.size + result = full + nowTruncated = full.size >= limit + } + } + this.owner = owner + this.from = from + this.to = to + this.truncated = nowTruncated + if (nowTruncated) truncatedReads++ + this.rows = result + return result + } + + /** A failed read leaves NO buffer: the next day must go back to the store rather than splice against + * a window nobody successfully filled. Returns empty, which is what the caller saw for a failed read + * before this class existed. */ + private fun failedRead(): List { + owner = null + // The range is cleared too, not just the owner. Leaving it stale would work only because + // `WindowedStreamPlan.plan` happens to test the owner BEFORE the bounds — a cross-file assumption + // that holds today and would break silently if those checks were ever reordered. Cheaper to be + // self-consistent than to depend on the order of someone else's guards. + from = 0 + to = 0 + rows = emptyList() + truncated = false + return emptyList() + } +} diff --git a/android/app/src/main/java/com/noop/analytics/SseDeltas.kt b/android/app/src/main/java/com/noop/analytics/SseDeltas.kt new file mode 100644 index 0000000000..c5e0d86b71 --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/SseDeltas.kt @@ -0,0 +1,114 @@ +package com.noop.analytics + +import org.json.JSONArray +import org.json.JSONObject + +// SseDeltas.kt — pure SSE delta parsing for AI provider streaming. +// +// The verifiable core of K1 (streaming): given one SSE `data:` payload, extract the text delta +// for the provider's wire shape. Pure + deterministic so it is unit-testable without a network, +// and byte-identical to the Swift twin `StrandAnalytics.SseDeltas`. The streaming HTTP glue +// (OkHttp source / URLSession.bytes) is thin and calls into these pure functions per line. +// +// No network, no I/O — only org.json (Android core). The anonymity / no-raw-egress posture is +// unchanged: streaming changes transport, not payload. + +object SseDeltas { + + /** The SSE stream terminator sent by OpenAI-compatible servers. */ + const val DONE_MARKER = "[DONE]" + + // MARK: - SSE line splitting + + /** Strip the `data: ` prefix from one SSE line and return the raw payload, or null for blank + * lines (event separators), comments (`: …`), or non-data fields (`event:`, `id:`, etc.). + * Pure; byte-twin of Swift `dataPayload(fromLine:)`. */ + fun dataPayload(fromLine: String): String? { + val trimmed = fromLine.trim() + if (trimmed.isEmpty()) return null + if (trimmed.startsWith(":")) return null + if (trimmed.startsWith("data:")) { + var payload = trimmed.substring("data:".length) + if (payload.startsWith(" ")) payload = payload.substring(1) + return payload + } + return null + } + + /** True when a data payload is the `[DONE]` terminator (OpenAI-compatible). Pure. */ + fun isDone(payload: String): Boolean = payload.trim() == DONE_MARKER + + // MARK: - Per-provider delta extraction + + /** Extract the text delta from an OpenAI-compatible SSE data payload. + * Shape: `{"choices":[{"delta":{"content":"hello"}}]}` → `"hello"`. + * Returns null for a `[DONE]` line, a payload with no choices, or a non-content delta. + * Pure; byte-twin of Swift `openAiDelta`. */ + fun openAiDelta(payload: String): String? { + if (isDone(payload)) return null + return try { + val obj = JSONObject(payload) + val choices = obj.optJSONArray("choices") ?: return null + val first = choices.optJSONObject(0) ?: return null + val delta = first.optJSONObject("delta") ?: return null + delta.optString("content").takeIf { it.isNotEmpty() } + } catch (e: Exception) { null } + } + + /** Extract the text delta from a Gemini SSE data payload (`:streamGenerateContent?alt=sse`). + * Shape: `{"candidates":[{"content":{"parts":[{"text":"hello"}]}}]}` → `"hello"`. + * Pure; byte-twin of Swift `geminiDelta`. */ + fun geminiDelta(payload: String): String? { + return try { + val obj = JSONObject(payload) + val candidates = obj.optJSONArray("candidates") ?: return null + val first = candidates.optJSONObject(0) ?: return null + val content = first.optJSONObject("content") ?: return null + val parts = content.optJSONArray("parts") ?: return null + val sb = StringBuilder() + for (i in 0 until parts.length()) { + val text = parts.optJSONObject(i)?.optString("text") + if (!text.isNullOrEmpty()) sb.append(text) + } + sb.toString().takeIf { it.isNotEmpty() } + } catch (e: Exception) { null } + } + + /** Extract the text delta from an Anthropic SSE data payload. + * Shape: `{"type":"content_block_delta","delta":{"type":"text_delta","text":"hello"}}` → `"hello"`. + * Returns null for non-text-delta events. Pure; byte-twin of Swift `anthropicDelta`. */ + fun anthropicDelta(payload: String): String? { + return try { + val obj = JSONObject(payload) + if (obj.optString("type") != "content_block_delta") return null + val delta = obj.optJSONObject("delta") ?: return null + if (delta.optString("type") != "text_delta") return null + delta.optString("text").takeIf { it.isNotEmpty() } + } catch (e: Exception) { null } + } + + // MARK: - Full-stream reassembly (test helper) + + /** Reassemble the full reply text from a complete SSE stream (all lines, in order) for the + * given provider shape. Pure; used by tests to assert the streamed text equals the non-streamed + * reply. Byte-twin of Swift `reassemble`. */ + fun reassemble(lines: List, provider: SseProvider): String { + val sb = StringBuilder() + for (line in lines) { + val payload = SseDeltas.dataPayload(fromLine = line) + if (payload == null) continue + val delta: String? = if (provider == SseProvider.OPEN_AI || provider == SseProvider.CUSTOM) { + SseDeltas.openAiDelta(payload) + } else if (provider == SseProvider.GEMINI) { + SseDeltas.geminiDelta(payload) + } else { + SseDeltas.anthropicDelta(payload) + } + if (delta != null) sb.append(delta) + } + return sb.toString() + } +} + +/** The provider wire shape to use for delta extraction. CUSTOM shares the OpenAI shape. */ +enum class SseProvider { OPEN_AI, CUSTOM, GEMINI, ANTHROPIC } diff --git a/android/app/src/main/java/com/noop/analytics/StepsCounter.kt b/android/app/src/main/java/com/noop/analytics/StepsCounter.kt index 401284d3a8..3261b7988d 100644 --- a/android/app/src/main/java/com/noop/analytics/StepsCounter.kt +++ b/android/app/src/main/java/com/noop/analytics/StepsCounter.kt @@ -6,38 +6,59 @@ import com.noop.data.StepSample * Wrap-aware step derivation from the strap's cumulative `step_motion_counter@57`, shared by the daily * total ([AnalyticsEngine.analyzeDay]) and any windowed total (a manual workout's `[start, end]`, #398). * - * `step_motion_counter@57` is a CUMULATIVE u16 running counter: it climbs while you move, holds flat when - * still, and wraps at 65536. The motion-tick total over a set of records is the SUM of WRAP-AWARE - * increments of that counter — `delta = (cur - prev) and 0xFFFF` — with a per-user `stepTicksPerStep` - * calibration applied by the caller AFTERWARDS (this returns the raw pre-calibration tick total, so the two - * callers can never disagree on the counter math). The raw total is an ESTIMATE (@57 counts motion ticks, - * not validated steps), not cloud/clinical parity. + * `step_motion_counter@57` is a CUMULATIVE u16 motion counter: it climbs for both locomotion and some + * non-step wrist motion, and wraps at 65536. On classed WHOOP 5/MG records, the increment ending at each + * sample is counted only when the strap labels that sample walk (1) or run (2); still (0) and unknown are + * rejected. A wholly unclassed legacy window retains the old counter-only estimate so pre-migration history + * remains readable. The caller applies its per-user `stepTicksPerStep` calibration afterwards. The result is + * still an estimate, not cloud/clinical parity. * * Byte-for-byte twin of the Swift `StepsCounter.stepsInWindow`. */ object StepsCounter { - /** - * The largest wrap-aware increment treated as real motion between two adjacent 1 Hz records. A delta - * at/above this is a big time-gap / disconnect boundary between sync sessions (or a firmware reboot, - * byte-indistinguishable from a u16 wrap), NOT real steps — dropped so gaps don't inflate the total. - * Real 1 Hz motion never ticks this fast between adjacent records. (#132/#276/#316) - */ + private val LOCOMOTION_ACTIVITY_CLASSES = setOf(1, 2) + + internal fun hasActivityClasses(samples: List): Boolean = + samples.any { it.activityClass != null } + + internal fun shouldCountDelta(activityClass: Int?, hasActivityClasses: Boolean): Boolean = + !hasActivityClasses || activityClass in LOCOMOTION_ACTIVITY_CLASSES + + /** Absolute reboot/wrap guard retained independently from the rate plausibility gate below. */ const val MAX_STEP_DELTA = 512 + /** Four ticks/second is already 240 steps/minute. A larger one-second increment is wrist motion, + * corruption or delayed counter publication, not plausible gait. The allowance scales with the real + * timestamp gap so seven ticks across two seconds survive a missing 1 Hz record. */ + const val MAX_TICKS_PER_SECOND = 4 + + internal fun isPlausibleDelta(previousTs: Long, currentTs: Long, delta: Int): Boolean { + if (delta !in 1 until MAX_STEP_DELTA) return false + val elapsed = currentTs - previousTs + if (elapsed <= 0L) return false + val rateAllowance = if (elapsed >= MAX_STEP_DELTA / MAX_TICKS_PER_SECOND) { + MAX_STEP_DELTA - 1L + } else { + elapsed * MAX_TICKS_PER_SECOND + } + return delta.toLong() <= rateAllowance + } + /** - * Raw wrap-aware motion-tick total across [samples] — the sum of positive consecutive - * `step_motion_counter@57` increments in `[1, MAX_STEP_DELTA)`. Sorts by `ts` internally, so the caller - * may pass an unsorted window (already filtered to the range it cares about). Returns `null` when there - * are fewer than two samples or no forward movement (so "no data" stays distinct from a real zero). The - * caller applies its `stepTicksPerStep` calibration to the returned ticks. + * Raw wrap-aware locomotion-tick total across [samples]. When any sample carries [StepSample.activityClass], + * each positive increment is attributed to the later sample and retained only for walk/run. When the + * whole window is legacy-unclassed, all valid increments retain the historical counter-only fallback. + * Sorts by `ts` internally and returns `null` for fewer than two samples or no retained movement. */ fun stepsInWindow(samples: List): Int? { val sorted = samples.sortedBy { it.ts } if (sorted.size < 2) return null + val hasActivityClasses = hasActivityClasses(sorted) var total = 0 for (i in 1 until sorted.size) { val delta = (sorted[i].counter - sorted[i - 1].counter) and 0xFFFF // wrap-aware u16 increment - if (delta in 1 until MAX_STEP_DELTA) total += delta // ignore a delta >= 512 (gap/reset) + val isLocomotion = shouldCountDelta(sorted[i].activityClass, hasActivityClasses) + if (isLocomotion && isPlausibleDelta(sorted[i - 1].ts, sorted[i].ts, delta)) total += delta } return if (total > 0) total else null } diff --git a/android/app/src/main/java/com/noop/analytics/StrainScorer.kt b/android/app/src/main/java/com/noop/analytics/StrainScorer.kt index 2686220cc8..99bb9d37af 100644 --- a/android/app/src/main/java/com/noop/analytics/StrainScorer.kt +++ b/android/app/src/main/java/com/noop/analytics/StrainScorer.kt @@ -250,6 +250,7 @@ object StrainScorer { fun effectiveEffort(live: Double?, stored: Double?): Double? { if (live == null) return stored if (stored == null) return live + if (live == 0.0 && stored == 0.0) return 0.0 return kotlin.math.max(live, stored) } @@ -373,6 +374,38 @@ object StrainScorer { return exp(maxStrain * sumXX / sumXY) } + /** + * One line naming what an Effort score was computed FROM, or why it could not be computed. + * + * The gap this closes: [strain] is the only score in the app with no trace at all. WorkoutDetector, + * SleepStager and both engines each emit a funnel; the number on the Today hero ring emitted nothing, + * so a log could not distinguish "measured, and the day was genuinely calm" from "could not measure". + * A reader looking for the latter finds `effort detect`, which is WORKOUT-BOUT detection and answers a + * different question — a confusion that has already produced one wrong diagnosis. + * + * `enough` is the [strain] gate spelled out: dense (>= minReadings) OR sparse-but-sustained. `trimp` + * and `strain` are absent when the gate refused, which is exactly the case a bare 0 hides. + */ + fun scoreFunnelLine( + day: String, + hrSamples: Int, + enough: Boolean, + maxHR: Double, + maxHRProvided: Boolean, + restingHR: Double, + method: Method, + trimp: Double?, + strain: Double?, + ): String = + "effort score day=$day hr=$hrSamples enough=$enough" + + " hrMax=${round1(maxHR)}(${if (maxHRProvided) "provided" else "default"})" + + " rhr=${round1(restingHR)} reserve=${round1(maxHR - restingHR)}" + + " method=${method.name.lowercase()}" + + " trimp=${trimp?.let { round1(it) } ?: "n/a"} strain=${strain?.let { round1(it) } ?: "n/a"}" + + /** One decimal, locale-independent, so two platforms' lines compare byte for byte. */ + private fun round1(v: Double): String = String.format(java.util.Locale.US, "%.1f", v) + // ---- Public API ---- /** @@ -398,6 +431,11 @@ object StrainScorer { // null (the default) resolves to the denominator that BELONGS to [method] — Edwards' 7201, or // Banister's sex-dependent ceiling. Pass a value only to override. denominator: Double? = null, + // Optional diagnostic sink. Null by default and the line is built ONLY when one is supplied, so a + // scoring pass that nobody is watching pays nothing — which matters because a pass re-scores many + // days. Callers hand this in for the day worth explaining, not for all of them. + diag: ((String) -> Unit)? = null, + day: String = "", ): Double? { val resolvedDenominator = denominator ?: logMapDenominator(method, sex) val effMax = maxHR ?: defaultMaxHR().toDouble() @@ -411,7 +449,15 @@ object StrainScorer { } else -> false } - if (!enoughData || effMax <= restingHR) return null + if (!enoughData || effMax <= restingHR) { + // The refusal is the half a bare number cannot show: null here and 0.0 on a calm day look + // identical on the ring, and only one of them is a measurement. + diag?.let { + it(scoreFunnelLine(day, hr.size, enoughData, effMax, maxHR != null, restingHR, method, + trimp = null, strain = null)) + } + return null + } val durations = sampleDurationsMinutes(hr) val hrReserve = effMax - restingHR @@ -428,6 +474,11 @@ object StrainScorer { edwardsTRIMP(hr, restingHR, hrReserve, durations) } } - return trimpToStrain(trimp, resolvedDenominator) + val scored = trimpToStrain(trimp, resolvedDenominator) + diag?.let { + it(scoreFunnelLine(day, hr.size, enoughData, effMax, maxHR != null, restingHR, method, + trimp = trimp, strain = scored)) + } + return scored } } diff --git a/android/app/src/main/java/com/noop/analytics/StreamReadCap.kt b/android/app/src/main/java/com/noop/analytics/StreamReadCap.kt new file mode 100644 index 0000000000..eff359a8de --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/StreamReadCap.kt @@ -0,0 +1,130 @@ +package com.noop.analytics + +import kotlin.math.ceil + +/** + * Per-stream read caps for the `analyzeRecent` sliding windows (#1538). + * + * One shared cap of 200,000 covered both heavy streams, and it was sized for HR. A field capture + * (2026-09-05, WHOOP 5/MG, 21 nights) shows what that cost: + * + * ``` + * hr[read=1723815 served=1826919 truncated=0] + * rr[read=2563444 served=610905 truncated=10] + * ``` + * + * Ten R-R windows came back AT the cap. [SlidingStreamWindow.truncatedReads] is explicit about what + * that means - "a non-zero count means a number may be wrong, not merely slow" - because the read is + * `ORDER BY ts ASC LIMIT`, so what gets dropped is the NEWEST rows. Every HRV number derived from one of + * those windows was computed on a night missing its tail, silently. + * + * ## Why R-R and not HR + * + * Measured, not reasoned from beat rate - the capture contradicts the obvious argument. R-R is one row + * per BEAT, so it "should" outrun HR's 1/s, yet a stride read in that same log returned 81,009 R-R rows + * against 107,415 HR: R-R is recorded in BURSTS, not continuously, so its average over a window sits + * BELOW HR's while its peaks run far above. Averages are why one cap looked safe; peaks are what + * truncated it. + * + * What is certain is the behaviour: across 21 nights R-R came back at the 200,000 cap ten times and HR + * never did. HR's own margin is the other half of the story - a full 54-hour window at 1/s is ~194,400 + * rows, 3% under the cap, so HR was never comfortable either, merely lucky. + * + * The caps are therefore sized against what a window can PEAK at, not what it averages. The rate + * constants below are deliberately generous for that reason: they are a ceiling to stay clear of, not a + * prediction of typical density. + * + * So the bug was not the number being too small. It was ONE number serving two streams whose peak + * densities differ, where a value comfortable for one is a silent truncation for the other. + * + * ## The invariant + * + * A cap must exceed what a full window can legitimately hold, or a complete read is indistinguishable + * from a truncated one. `cap exceeds a full window` pins exactly that, per stream, so a future change to + * the window span or a stream's rate fails a test rather than quietly clipping a night. + * + * Note the cap is a ceiling, not an allocation: a read still returns only the rows that exist. Raising + * it costs nothing on a normal night and buys correctness on a dense one. Swift twin: `StreamReadCap`. + */ +object StreamReadCap { + + /** + * How far BEFORE the day boundary the pass reads. EVERY lookback in the engine takes this value - + * the per-day `from`, the window start, the skin-anchor scan - so the window and the caps derived + * from it cannot drift apart - widening the lookback there without + * resizing the caps here is precisely how a stream starts truncating again, and is the "one number in + * two places" shape this whole type exists because of. + */ + const val LOOKBACK_SECONDS = 30 * 3_600 + + /** How far AFTER it, capped at `now` for a day still in progress. A whole day. */ + const val FORWARD_SECONDS = 24 * 3_600 + + /** The per-day read window: `dayStart - 30h` through the night, i.e. 54 hours. */ + const val WINDOW_SECONDS = LOOKBACK_SECONDS + FORWARD_SECONDS + + /** HR: one sample per second. */ + const val HR_ROWS_PER_SECOND = 1.0 + + /** + * R-R at its PEAK. The measured average is below 1/s (it arrives in bursts, with gaps), but bursts + * are what reach a cap - and #1008's cross-second overcount can double the rows a burst produces. + * 2/s is a ceiling chosen to stay clear of, not a claim about typical density. + */ + const val RR_ROWS_PER_SECOND = 2.0 + + /** + * Gravity: one sample per second, like HR, and read over the SAME 54-hour window - the code's claim + * that "the other eight streams are thousands of rows" does not hold for this one. A field capture + * measured 192,698 gravity rows, 96% of the old shared cap. + * + * It is the most dangerous of the three, because it is a PLAIN read rather than a + * [SlidingStreamWindow]: nothing counts a truncation here, so a clip would be silent even by the + * standard that caught R-R. Sleep STAGING is what consumes it, so the cost of a clipped read is a + * mis-staged night rather than a slow one. + */ + const val GRAVITY_ROWS_PER_SECOND = 1.0 + + /** + * Headroom over a full window, so a legitimate read cannot land ON the cap and be mistaken for a + * truncated one. + */ + const val HEADROOM = 1.5 + + /** + * The cap on the sleep engine's skin-temp reads, named rather than left a literal so the no-night + * line can say when one came back AT it. Skin reads OUTSIDE the engine (a chart, an export) keep + * their own limits - this is not a repo-wide skin cap. + * + * Not derived from a row rate like the caps below: skin's density was never measured. Skin rides the + * record that carries gravity, and gravity ranged from 177 to 192,698 rows across 54 HOURS in one + * capture, so whether 200,000 is generous or tight is genuinely unknown - which is the reason to + * print the count. Unchanged in value: this names what both platforms already read at. + * + * Shared by two reads with very different spans - the night window, and the ~21-day WHOOP 4.0 anchor + * scan. Only the window read's count reaches the no-night line, so an `atCap=skin` marker means THAT + * read was clipped; a clipped anchor scan still goes unreported. + */ + const val SKIN = 200_000 + + /** The cap for a stream producing [rowsPerSecond] at its densest. */ + fun cap(rowsPerSecond: Double): Int = ceil(WINDOW_SECONDS * rowsPerSecond * HEADROOM).toInt() + + /** + * 291,600 - a full HR window plus half again. + * + * STORED, not a getter. A window's read cap and its truncation threshold must be the same number, + * and the engine names this in both slots - a stored value makes those two references provably one + * value rather than two evaluations that merely ought to agree. + */ + val HR: Int = cap(HR_ROWS_PER_SECOND) + + /** 583,200 - a full R-R window at its densest, plus half again. Stored for the reason [HR] gives. */ + val RR: Int = cap(RR_ROWS_PER_SECOND) + + /** + * 291,600 - the same shape as [HR], for the same 54-hour window. Only the 54-hour read needs it; the + * day-scoped gravity reads span 24 hours and cannot approach any of these. + */ + val GRAVITY: Int = cap(GRAVITY_ROWS_PER_SECOND) +} diff --git a/android/app/src/main/java/com/noop/analytics/WindowedStreamPlan.kt b/android/app/src/main/java/com/noop/analytics/WindowedStreamPlan.kt new file mode 100644 index 0000000000..feb8e4b56b --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/WindowedStreamPlan.kt @@ -0,0 +1,100 @@ +package com.noop.analytics + +/** + * How to satisfy one day's windowed stream read given what the previous day already read (#1538). + * + * `analyzeRecent`'s pass-1 loop walks BACKWARDS — `dayStart = nowLocalMidnight - offset * 24h` — and reads + * a 54-hour window around each night (`dayStart - 30h` .. next local midnight). On a 24-hour stride that + * makes consecutive windows overlap by 30 hours, so every row is materialised about 2.25 times per pass. + * The `analyzeRecent cost prep=…ms score=…ms` line exists to decide whether that is worth narrowing, and a + * field log answered it: prep 48129ms against score 31516ms on a cold 21-day pass, so the reads are the + * larger half and the overlap is worth removing. + * + * This is the DECISION half, kept pure so both platforms can be pinned against one oracle. Splicing the + * rows is mechanical and stays with the caller. Twin of Swift `WindowedStreamPlan`. + * + * The planner REFUSES rather than guesses. Every case it cannot prove safe returns [FullRead], which is + * byte-for-byte the behaviour that shipped before this existed — the same stance `daySliceFromNight` takes + * when it declines. An optimisation on the scoring path may only ever skip work, never change a number. + */ +object WindowedStreamPlan { + + /** + * What the sliding windows saved, for the line emitted beside `analyzeRecent`'s `prep`/`score` split + * — the measurement the decision to build them was made from. + * + * `read` is rows fetched from the store, `served` rows a buffer supplied instead. A pass where + * `served` is ~0 means the windows are declining — truncation, an owner flip, or a gap left by a + * dayCache hit — and the reads are back to exactly what they were. That is the honest outcome rather + * than a silent one, which is why the counters ship rather than just the speedup. + * + * `truncated` separates the one of those causes that is also a BUG. The reads are + * `ORDER BY ts ASC LIMIT`, so a read at the cap drops the NEWEST rows and the day is scored on a + * window missing its tail, with nothing else in the log to say so. A field log showed a strap at + * 192,680 rows against the 200,000 cap — 96% — with `served` near zero on one stream and half on the + * other, and no way to tell which cause fired. Hence this counter rather than a guess. + * + * Lives HERE rather than on the engine so both platforms put it in the same place and both are + * covered by default CI. Byte-identical to the Swift `WindowedStreamPlan.logLine`. + */ + fun logLine( + hrRead: Long, + hrServed: Long, + hrTruncated: Long, + rrRead: Long, + rrServed: Long, + rrTruncated: Long, + ): String = + "analyzeRecent windows hr[read=$hrRead served=$hrServed truncated=$hrTruncated] " + + "rr[read=$rrRead served=$rrServed truncated=$rrTruncated]" + + /** What the caller should do to obtain rows for the requested window. */ + sealed interface Plan { + /** Read the whole window from the store; nothing usable is buffered. */ + data object FullRead : Plan + /** The buffer already covers the window; slice it, read nothing. */ + data object Serve : Plan + /** + * Read only `[readFrom, readTo]` (inclusive, both ends) and prepend it to the buffer. `readTo` is + * one second BELOW the buffer's current start, so the boundary row is never read twice — the + * store's range predicate is inclusive at both ends. + */ + data class Extend(val readFrom: Long, val readTo: Long) : Plan + } + + /** + * Plan the read for `[from, to]` against a buffer holding `[cachedFrom, cachedTo]` for [cachedOwner]. + * + * [cachedOwner] null means nothing is buffered. [cachedTruncated] means the buffered read hit the + * store's row limit, so its contents do not represent its range — that buffer can never be sliced, for + * the reason `daySliceFromNight` gives. + * + * After the caller acts on the plan the buffer covers exactly `[from, to]`: rows above `to` are + * dropped, which is what keeps the peak footprint at one window rather than the whole pass. A backward + * walk never asks for them again. + */ + fun plan( + cachedOwner: String?, + cachedFrom: Long, + cachedTo: Long, + cachedTruncated: Boolean, + owner: String, + from: Long, + to: Long, + ): Plan { + // Nothing buffered, a different strap owns this day, or the buffered read was cut off at the row + // limit — none of these can be sliced, and a wrong slice is a wrong score. + if (cachedOwner == null || cachedOwner != owner || cachedTruncated) return Plan.FullRead + // A degenerate or inverted buffer holds nothing meaningful. + if (cachedFrom > cachedTo) return Plan.FullRead + // An inverted request is not this planner's business to repair. + if (from > to) return Plan.FullRead + // The buffer covers the request outright. + if (from >= cachedFrom && to <= cachedTo) return Plan.Serve + // The ONLY extension this walk produces: the window moved earlier, and its tail is still inside + // what is buffered. Anything else (a window that moved forward, or one disjoint from the buffer) + // is not the backward stride this exists for, so it reads in full rather than being reasoned about. + if (from < cachedFrom && to in cachedFrom..cachedTo) return Plan.Extend(from, cachedFrom - 1) + return Plan.FullRead + } +} diff --git a/android/app/src/main/java/com/noop/analytics/WorkoutDetector.kt b/android/app/src/main/java/com/noop/analytics/WorkoutDetector.kt index 83058056c3..773595d889 100644 --- a/android/app/src/main/java/com/noop/analytics/WorkoutDetector.kt +++ b/android/app/src/main/java/com/noop/analytics/WorkoutDetector.kt @@ -172,7 +172,7 @@ object WorkoutDetector { * day was measured against — the same privacy class as the sibling `sleep day=` line. */ fun detectionFunnelLine(day: String, f: DetectionFunnel): String = - "effort detect day=$day hr=${f.hrSamples} motion=${f.motionSamples} " + + "workout detect day=$day hr=${f.hrSamples} motion=${f.motionSamples} " + "restHR=${round0(f.restingHR)} floor=${round0(f.hrFloor)} " + "motionOK=${f.motionPassed} hrMissing=${f.hrMissing} hrTooLow=${f.hrTooLow} " + "active=${f.active} runs=${f.runs} bridged=${f.bridged} " + @@ -585,6 +585,15 @@ object WorkoutDetector { * Faithful port of the `Calories` enum that ships inside WorkoutDetector.swift. */ object Calories { + /** Whole-day BMR + activity-above-rest split over supported sample intervals. */ + data class DayEnergyEstimate( + val restingKcal: Double, + val activeKcal: Double, + val observedSeconds: Double, + ) { + val totalKcal: Double get() = restingKcal + activeKcal + } + /** Sex-specific BMR + active-EE coefficients. Mirrors Swift `Calories.Coeffs`. */ data class Coeffs( @@ -641,6 +650,16 @@ object Calories { * gate to 50% HRR so the gross rate only applies at genuine exercise-level HR. */ const val dayActiveHRRFraction: Double = 0.50 + /** + * Longest gap over which one daily HR reading may carry RESTING energy. + * + * Resting metabolism continues across a dropout, so a reading may carry the BMR rate into the + * gap — but only so far, or a disconnected evening would bank a full night of resting kcal that + * was never observed. ACTIVE energy is capped separately and much tighter, at the inferred + * cadence (see [estimateDayEnergy]): a gap is evidence of a missing sensor, never of exercise. + */ + const val dayMaxObservedGapS: Double = 60.0 + const val dayMaxObservedSpanS: Double = 86_400.0 const val workoutDivisor: Double = 251.04 // 60 s/min × 4.184 kJ/kcal fun resolveCoeffs(sex: String): Coeffs = when (sex.lowercase()) { @@ -748,11 +767,10 @@ object Calories { } /** - * APPROXIMATE whole-day total energy estimate (kcal) from the full day's HR - * samples. Per-second model: below the day activeThreshold (resting + - * [dayActiveHRRFraction] HRR) a sample burns the resting BMR rate, above it the - * Keytel active rate — FLOORED at the resting rate so a day-second can never be - * credited LESS than resting metabolism. + * APPROXIMATE whole-day resting + active energy estimate from the full day's HR + * samples. Resting BMR is integrated once over capped, supported sample intervals, + * independent of HR cadence. Supported high-HR intervals then add only the Keytel + * energy ABOVE that resting floor. * * The day path uses [dayActiveHRRFraction] (50% HRR), NOT the 30% the bout detector * uses ([activeHRRFraction]). The Keytel 2005 equation is validated for genuine @@ -763,28 +781,28 @@ object Calories { * exercise-level HR only; the bout path is UNCHANGED — Keytel is appropriate there, * on a real detected/manual workout. * - * Each HR sample = ONE second of data (1 Hz strap), counted flat — this path - * deliberately does NOT use the bout estimator's elapsed-time-per-sample weighting. - * The day feed is a raw, non-gap-filled union of the day's HR (it is NOT motion-gated - * the way a bout is), so capping each gap at mergeGapS (150 s) would credit up to - * ~150 s of active burn to a single isolated elevated sample — over-counting by ~150x - * on gappy days. Flat one-second-per-sample is the conservative, stable choice for the - * day total. This is an on-device estimate from heart rate alone — NOT laboratory - * calorimetry, NOT Apple/WHOOP cloud parity, NOT medical advice. + * Cadence is inferred from the median positive timestamp gap and capped at + * [dayMaxObservedGapS]. Each sample carries resting energy, and a high-HR sample carries + * active energy, to the next sample for at most that cap. This can credit up to 60 s + * instead of the old flat 1 s for a reading on a gappy day, but never a whole disconnect. + * Thus a 30 s sparse stream and a 1 Hz stream covering the same activity produce + * comparable energy. + * + * This is an on-device estimate from heart rate alone — NOT laboratory calorimetry, + * NOT Apple/WHOOP cloud parity, NOT medical advice. * - * @param hrSamples the whole day's HR samples (one second each). + * @param hrSamples the whole day's timestamped HR samples. * @param profile weight/height/age/sex for the BMR + active-EE coefficients. * @param hrmax effective HRmax (bpm); null → 220. * @param restingHR resting HR (bpm); null → 60. - * @return total estimated kcal for the day (>= 0). */ - fun estimateDayCalories( + fun estimateDayEnergy( hrSamples: List, profile: UserProfile, hrmax: Double?, restingHR: Double?, - ): Double { - if (hrSamples.isEmpty()) return 0.0 + ): DayEnergyEstimate { + if (hrSamples.isEmpty()) return DayEnergyEstimate(0.0, 0.0, 0.0) val weightKg = if (profile.weightKg > 0) profile.weightKg else 70.0 val heightCm = if (profile.heightCm > 0) profile.heightCm else 170.0 @@ -802,18 +820,64 @@ object Calories { // restingHR → base model, unchanged. Constant across the day. val vo2max = vo2maxFor(effHRmax, restingHR) - var totalKcal = 0.0 - for (s in hrSamples) { - val bpm = s.bpm.toDouble() - totalKcal += if (bpm < activeThreshold) { - restingRate + // Ties are REACHABLE: hrSample is keyed (deviceId, ts) and the day feed unions devices, so a + // two-strap day carries two readings for the same second. Only the LAST of a tied run gets the + // interval (the earlier ones measure a zero gap), so tie order decides the day's active energy. + // Ordering ties by DESCENDING bpm hands the interval to the LOWER reading — the conservative + // direction for a path whose history is over-counting. Sorting on ts alone left this to the + // sort's stability, which Kotlin guarantees and Swift explicitly does not. + val ordered = hrSamples.sortedWith(compareBy { it.ts }.thenByDescending { it.bpm }) + val positiveGaps = ordered.zipWithNext { a, b -> (b.ts - a.ts).toDouble() } + .filter { it > 0.0 } + .sorted() + val nominalSampleS = if (positiveGaps.isEmpty()) { + 1.0 + } else { + val mid = positiveGaps.size / 2 + val median = if (positiveGaps.size % 2 == 0) { + (positiveGaps[mid - 1] + positiveGaps[mid]) / 2.0 } else { - // Floor the active rate at the resting BMR rate: a worn day-second never - // burns LESS than resting metabolism, even where the Keytel value dips low - // for some profiles just above the gate. - maxOf(restingRate, activeKcalPerS(coeffs, bpm, effHRmax, weightKg, age, vo2max)) + positiveGaps[mid] } + minOf(median, dayMaxObservedGapS) } - return totalKcal + val observedSeconds = minOf( + dayMaxObservedSpanS, + positiveGaps.fold(nominalSampleS) { total, gap -> + total + minOf(gap, dayMaxObservedGapS) + }, + ) + val restingKcal = restingRate * observedSeconds + + var activeKcal = 0.0 + for (i in ordered.indices) { + // Active carry is capped at the INFERRED CADENCE, not at the wider resting cap. A 30 s + // stream still carries its full 30 s (the cadence bug this fixes), and a 1 Hz day carries + // 1 s so the legacy total is reproduced exactly — but a dropout in an otherwise dense day + // no longer credits a full minute of exercise to the last reading before it. That was the + // objection the flat one-second model was written to avoid, and capping active at the + // cadence answers it rather than narrowing it from 150 s to 60 s. + val durationS = if (i < ordered.size - 1) { + val gap = (ordered[i + 1].ts - ordered[i].ts).toDouble() + if (gap > 0.0) minOf(gap, nominalSampleS) else 0.0 + } else { + nominalSampleS + } + val bpm = ordered[i].bpm.toDouble() + if (bpm < activeThreshold) continue + val grossRate = activeKcalPerS(coeffs, bpm, effHRmax, weightKg, age, vo2max) + activeKcal += maxOf(0.0, grossRate - restingRate) * durationS + } + return DayEnergyEstimate(restingKcal, activeKcal, observedSeconds) + } + + /** Backward-compatible total-kcal facade for the stored daily metric. */ + fun estimateDayCalories( + hrSamples: List, + profile: UserProfile, + hrmax: Double?, + restingHR: Double?, + ): Double { + return estimateDayEnergy(hrSamples, profile, hrmax, restingHR).totalKcal } } diff --git a/android/app/src/main/java/com/noop/ble/Backfiller.kt b/android/app/src/main/java/com/noop/ble/Backfiller.kt index 27e2284c5c..fe8a6e68d1 100644 --- a/android/app/src/main/java/com/noop/ble/Backfiller.kt +++ b/android/app/src/main/java/com/noop/ble/Backfiller.kt @@ -63,6 +63,14 @@ class Backfiller( * HISTORY_END metadata.data[10:18]) the high-freq-sync ack form requires. */ private val ackTrim: (trim: Long, endData: ByteArray) -> Unit, + /** + * #1635: what one offload chunk actually stored, handed up so the client can tally it per LINK and + * name it beside the link epitaph. The offload is the ONLY path that banks gravity, respiratory, + * skin temperature, SpO2 and steps — the realtime decoder yields hr/rr/events/battery and nothing + * else — so a link summary that omitted this could not distinguish an unbonded strap, which defers + * backfill entirely, from a healthy one. Defaulted so existing constructions are unchanged. + */ + private val onBankedOffload: (counts: InsertCounts) -> Unit = {}, /** * Fires after a chunk's decoded rows are durably committed AND acked — i.e. real new data just * landed. Lets the client schedule on-device scoring right away instead of leaving fresh history @@ -228,6 +236,12 @@ class Backfiller( */ private var loggedNoCursor = false + /** #1754: whether THIS session saw the trim=0xFFFFFFFF "no valid flash cursor" sentinel. Exposed so + * the empty-offload banner can distinguish the clock/charge state (no cursor — the existing copy is + * correct) from a strap that has a valid, advancing flash cursor but banks no sensor records (NOT a + * clock problem — points at the sensor front-end or power). Read-only from outside. */ + val sawNoFlashCursor: Boolean get() = loggedNoCursor + /** * #773: logged once per session the first time a HISTORY_END's own timestamp is dated implausibly far * in the FUTURE (a corrupt strap RTC). Distinct from #547's per-record drop tally: this fires on the @@ -563,6 +577,7 @@ class Backfiller( // key has already absorbed part of it. val rrCensus = com.noop.analytics.RrEmissionStats.compute(decoded.rr.map { it.ts.toInt() to it.rrMs }) val counts = repository.insert(decoded, deviceId) + onBankedOffload(counts) committed = decoded // Success-side observability (#150): tally what actually persisted so the session can emit // "persisted N rows (M with motion) across K night(s)" — the win-rate signal we never logged. @@ -797,6 +812,90 @@ class Backfiller( "right day. Fully charge the strap to 100% and reconnect so it re-syncs its clock; if it " + "persists, forget and re-pair the strap." } + + /** + * #1683: how far BEHIND the wall clock the strap's newest stored record may sit before a sync that + * banked nothing is worth explaining. Two days, not one: a strap left off-wrist overnight is + * ordinary, and this line only ever accompanies a completed offload that banked nothing anyway. + */ + const val STALE_RECORD_TOLERANCE_SECONDS = 2 * 86_400L + + /** Is the strap's newest stored record far enough in the past to be worth naming? A null or + * non-positive value is not a date and never qualifies. */ + fun isStaleNewestRecord(newestUnix: Long?, wallNowUnix: Long): Boolean = + newestUnix != null && newestUnix > 0L && + newestUnix <= wallNowUnix - STALE_RECORD_TOLERANCE_SECONDS + + /** + * #1683: the counterpart [futureRtcLine] never had. A strap that stopped banking weeks ago and a + * strap that is simply caught up produce the SAME "banked no sensor history" line today, so + * neither the user nor anyone reading their log can tell them apart. #1541 stayed open and vague + * for exactly that reason. + * + * Deliberately states the FACT and lets the condition carry the interpretation. A newest record + * two weeks old means the strap stopped recording IF it was being worn; if it sat in a drawer, the + * same number is unremarkable. Asserting a corrupt RTC here would be claiming more than the data + * supports, which is how this area has misled people before. + * + * It also says the part the existing advice omits: NOOP re-sends SET_CLOCK on every connect, so + * "charge it" alone has already been retried every session. The escalation and the + * compare-with-the-official-app test are what actually move a stuck case forward. + * + * Byte-identical to the Swift twin. No em-dash (project rule). + */ + fun staleRecordLine(newestUnix: Long, wallNowUnix: Long): String { + val ageDays = maxOf(0L, wallNowUnix - newestUnix) / 86_400L + return "Backfill: this sync banked nothing and the strap's newest stored record is about " + + "$ageDays day(s) old. If you have worn it since then, it has stopped saving history to " + + "its flash. NOOP already re-sends the clock on every connect, so charging alone may not " + + "be enough: charge to 100% and reconnect, then use Restart strap in Devices, and if that " + + "does not help forget and re-pair. If the official WHOOP app is also missing these days, " + + "the strap is the cause and not NOOP." + } + + /** + * #1683: the same honesty as [staleRecordLine], for the message the user actually READS. + * + * The standing banner says "fully charge it to 100%, then reconnect, and it should start banking + * again". It omits the one fact that makes the situation legible - how long the strap has been + * silent - and it PROMISES a recovery that has already failed every session for weeks, because + * NOOP re-sends SET_CLOCK on every connect and the charge advice has therefore been retried all + * along. A banner that keeps promising something that keeps not happening teaches people to + * distrust the app rather than their strap. + * + * Shorter than the log line: a banner is glanced at, not read. Same discipline though - state the + * age, condition the diagnosis on having worn it, and name the check that says whether NOOP is + * even involved. + * + * Byte-identical to the Swift twin. Not localized, matching the sibling `lastSyncError` copy on + * both platforms; localizing that surface is its own change. No em-dash (project rule). + */ + fun staleRecordBanner(newestUnix: Long, wallNowUnix: Long): String { + val ageDays = maxOf(0L, wallNowUnix - newestUnix) / 86_400L + return "Synced, but your strap handed over no stored history, and its newest saved record is " + + "about $ageDays day(s) old. If you have been wearing it since then, it has stopped saving " + + "to flash. Charge it to 100% and reconnect; NOOP already re-sets its clock every connect, " + + "so if that does not help, try Restart strap in Devices, then forget and re-pair. If the " + + "official WHOOP app is missing these days too, the strap is the cause and not NOOP." + } + + /** #1754: the banner for an empty offload whose flash cursor is VALID and ADVANCING — the strap + * is writing pages but banking no sensor records, so the clock/charge advice does not apply. The + * cause points at the sensor front-end or power state, not the RTC. Byte-identical to the Swift + * twin (`Backfiller.noSensorRecordsBanner`). No em-dash (project rule). */ + val noSensorRecordsBanner = + "Synced, but your strap handed over no sensor records - only its diagnostic output. The " + + "strap's flash cursor is valid and advancing, so this is not a clock problem; it points " + + "at the sensor front-end or power state. If this persists across reconnects, please " + + "share a strap log so the cause can be identified." + + /** #1754: the banner for an empty offload whose flash cursor is the 0xFFFFFFFF sentinel — the + * strap has no banked history at all, the clock/charge advice IS correct. Byte-identical to the + * Swift twin (`Backfiller.noFlashCursorBanner`). */ + val noFlashCursorBanner = + "Synced, but your strap had no stored history to hand over - only its diagnostic output. " + + "This usually means its clock has lost sync, so it isn't saving data to flash. Fully " + + "charge it to 100%, then reconnect, and it should start banking again." } } diff --git a/android/app/src/main/java/com/noop/ble/BondRefusalGiveUp.kt b/android/app/src/main/java/com/noop/ble/BondRefusalGiveUp.kt index f8e1ecbb38..31037474ef 100644 --- a/android/app/src/main/java/com/noop/ble/BondRefusalGiveUp.kt +++ b/android/app/src/main/java/com/noop/ble/BondRefusalGiveUp.kt @@ -20,11 +20,15 @@ package com.noop.ble */ class BondRefusalGiveUp( /** - * Consecutive bond refusals before we pause auto-reconnect + write the epitaph. 5 (not 2, where the + * Consecutive bond refusals before we PAUSE auto-reconnect + write the epitaph. 5 (not 2, where the * pairing HINT already shows): the hint asks the user to act; we give them several reconnect cycles to * do it before we stop hammering. A genuinely held/stale strap reaches 5 within a couple of minutes. + * + * This is the AUTH-REFUSAL number specifically, and [recordRefusal] takes a per-refusal override for + * that reason: an unanswered handshake asks the user for nothing, so waiting five cycles for a decision + * they cannot make just buys ~4.8s link drops. See [giveUpThresholdFor]. */ - private val giveUpThreshold: Int = 5, + val giveUpThreshold: Int = 5, ) { var refusals = 0 private set @@ -39,10 +43,14 @@ class BondRefusalGiveUp( /** * Record one bond refusal. Returns true if THIS refusal freshly crossed the give-up threshold (so the * caller pauses the reconnect + writes the epitaph exactly once). + * + * [threshold] defaults to the constructed [giveUpThreshold]; callers that can tell the two give-up + * CAUSES apart pass the one for the refusal in hand ([giveUpThresholdFor]). The latch is still reported + * exactly once whatever the threshold, so a lower one moves the crossing without duplicating it. */ - fun recordRefusal(): Boolean { + fun recordRefusal(threshold: Int = giveUpThreshold): Boolean { refusals += 1 - if (!gaveUp && refusals >= giveUpThreshold) { + if (!gaveUp && refusals >= threshold) { gaveUp = true return true } @@ -86,12 +94,20 @@ class BondRefusalGiveUp( * write is not evidence the strap is held by the official app, and the epitaph that asserts that is * reserved for an actual auth refusal. Says what was observed and what the user still gets. * + * #1635 follow-up: it used to end "Tap Connect to try the handshake again", which framed a strap + * that REFUSES pairing as a retryable failure. A field report read all of this and still asked how + * to fix it, so the ending now leads with pairing mode (reported once on #1635, hedged) and keeps + * Connect as the follow-up. It also no longer claims HRV and resting heart rate are unavailable: + * since #1884 an HR-only night reports both. + * * Pure; no em-dash. Byte-identical to the Swift `BondRefusalGiveUp.helloSuppressedHint`. */ fun helloSuppressedHint(): String = - "The secure handshake with your strap never completes, and the attempt itself is what drops " + - "the link. NOOP has switched it off for this strap so live heart rate keeps streaming. " + - "History sync stays unavailable until it pairs. Tap Connect to try the handshake again." + "The secure handshake with your strap never completes, and the attempt itself is what drops t" + + "he link. NOOP has switched it off for this strap so live heart rate keeps streaming. History" + + " sync stays unavailable until it pairs, and so do motion, skin temperature, SpO₂ and respira" + + "tory rate, so sleep is staged from heart rate alone. Some straps have paired again after bei" + + "ng put in pairing mode. Tap until the LEDs flash blue, then tap Connect." /** * #1635: the log epitaph for the suppression path. diff --git a/android/app/src/main/java/com/noop/ble/BondStateTrace.kt b/android/app/src/main/java/com/noop/ble/BondStateTrace.kt index 78e2978937..19fc5a503d 100644 --- a/android/app/src/main/java/com/noop/ble/BondStateTrace.kt +++ b/android/app/src/main/java/com/noop/ble/BondStateTrace.kt @@ -112,7 +112,13 @@ internal fun bondStateAtConnectLine(bondState: Int, address: String?): String { * * Polling the device directly removes the broadcast from the chain, so the answer no longer depends on * the component under suspicion. A `BOND_BONDING` here with no transition line above it convicts our - * receiver; a `BOND_NONE` clears it and puts the silence on Android. + * receiver; a `BOND_NONE` clears it. + * + * It does NOT, however, put the silence on Android — which is what this line used to say, and it was + * wrong. An HCI capture of exactly this case settled it: the phone DOES transmit an SMP Pairing Request, + * and a WHOOP 5/MG answers `Pairing Failed — Pairing Not Supported (0x05)`. A refused pairing ends at + * BOND_NONE with no BONDED transition, which is indistinguishable from never starting when all you can + * see is the bond state. Two causes, one observation; the old verdict picked one and stated it as fact. * * Deliberately a single delayed read rather than a subscription: one fact is wanted, not a stream. */ @@ -126,8 +132,11 @@ internal fun bondStatePollLine(bondState: Int, sawTransitionLine: Boolean): Stri " — pairing underway, as the transition line already said" bondState == android.bluetooth.BluetoothDevice.BOND_BONDED -> " — paired" !sawTransitionLine -> - " — createBond was accepted but the device never left this state and nothing was heard," + - " so Android did not begin pairing at all" + " — createBond was accepted and the bond did not complete. This is NOT evidence that Android" + + " stayed silent: a REFUSED pairing ends here too. On a WHOOP 5/MG an HCI capture shows the" + + " phone does transmit a Pairing Request and the strap answers \"Pairing Not Supported\"" + + " (SMP 0x05), which lands in exactly this state (#1635). Only an HCI capture separates a" + + " refused pairing from one never attempted" else -> " — back to this state after a transition" } return "bond state poll: $name$verdict" diff --git a/android/app/src/main/java/com/noop/ble/ClientHelloOutcome.kt b/android/app/src/main/java/com/noop/ble/ClientHelloOutcome.kt index 44d2786c7a..32695f64c9 100644 --- a/android/app/src/main/java/com/noop/ble/ClientHelloOutcome.kt +++ b/android/app/src/main/java/com/noop/ble/ClientHelloOutcome.kt @@ -111,6 +111,62 @@ internal fun completionIsClientHelloAck( return isHelloChar && helloOutstanding } +/** + * The shortest an ATT round trip can plausibly take, in milliseconds. + * + * BLE's minimum connection interval is 7.5ms. A peripheral may answer inside the same connection event, + * so this is not a hard floor and is NOT used to reject anything - it decides only whether the log points + * the reader at the timing. It earns its place from the field distribution, which is starkly bimodal: + * across 41 captures every hello either "completed" in 0, 4, 5 or 7ms, or produced no callback at all + * about 3150ms later. Nothing in between, and 0ms cannot be a round trip at all. + */ +internal const val MIN_PLAUSIBLE_ATT_ROUND_TRIP_MS = 8L + +/** + * Does a completed CLIENT_HELLO write prove the link is ENCRYPTED? + * + * No, and conflating the two is the second false bond. [completionIsClientHelloAck] answers a narrower + * question - "is this completion the hello's?" - and the ack branch then took a yes as proof of an + * encrypted just-works bond. Those are different facts. A write completion says the stack considers the + * write finished. Encryption is a property of the LINK, and on Android the only thing that attests it is + * the OS bond state. + * + * The field capture shows them disagreeing outright: "CLIENT_HELLO acked after 5ms", "encryptedBond + * family=whoop5", and then two seconds later "bond state poll: BOND_NONE" on the same link - with an HCI + * capture already showing this strap answers SMP `Pairing Not Supported`, so an encrypted bond is not + * merely absent but impossible. The app displayed "Bonded, streaming." for it. + * + * So the completion still drives the HANDSHAKE - subscribe the puffin chars, clock the strap, offload, + * all of which only need the strap to be listening - while `encryptedBond` waits for something that + * actually attests encryption: the OS bond state, or the strap's own BLE_BONDED event. + * + * ANDROID-ONLY, deliberately, and this one has no Swift twin. CoreBluetooth exposes no link-encryption + * or bond state at all, so Apple has nothing to supply for [osBonded] — a twin taking it would be handed + * a constant `false` and would suppress bonds that are genuine there. The gap is real on Apple too; it + * simply cannot be closed the same way, and pretending otherwise in a shared helper would be worse than + * leaving it Android-only and saying so. + */ +internal fun helloCompletionProvesEncryptedBond(osBonded: Boolean): Boolean = osBonded + +/** + * The line for a hello completion that did NOT come with encryption. + * + * Without it the split is invisible: the log would show the handshake proceeding and no bond claim, and a + * reader would have to infer why. It also carries the elapsed time, because on a completion faster than a + * connection interval the timing is itself the evidence that the callback came from the local stack rather + * than the strap - and a 0ms "round trip" is the clearest thing in the whole #1635 capture set. + */ +internal fun helloAckedWithoutEncryptionLine(elapsedMs: Long, osBondState: String): String = + "CLIENT_HELLO outcome: the write completed after ${elapsedMs}ms, but the OS bond state is" + + " $osBondState — a completed write says the stack finished it, NOT that the link is encrypted," + + " so this is not a bond." + + (if (elapsedMs < MIN_PLAUSIBLE_ATT_ROUND_TRIP_MS) + " ${elapsedMs}ms is under one BLE connection interval, so the callback most likely came from" + + " the local stack rather than the strap." + else "") + + " Continuing the handshake anyway — subscribing, clocking and offloading need the strap to be" + + " listening, not the link to be encrypted (#1635)." + /** * A pre-bond write completion on a 5/MG link that arrived with NO CLIENT_HELLO outstanding. * diff --git a/android/app/src/main/java/com/noop/ble/ExplicitBond.kt b/android/app/src/main/java/com/noop/ble/ExplicitBond.kt index 6c78814dd3..f2dad72418 100644 --- a/android/app/src/main/java/com/noop/ble/ExplicitBond.kt +++ b/android/app/src/main/java/com/noop/ble/ExplicitBond.kt @@ -32,6 +32,26 @@ package com.noop.ble * pairing API at all — on Apple platforms pairing happens only as a side effect of touching an encrypted * characteristic, which is the very mechanism this file exists because it does not work. There is nothing * to mirror, so an audit finding this one-sided should leave it rather than delete it as untwinned. + * + * ## Why the bond give-up reaches this request, having been built for the CLIENT_HELLO + * + * The two are separate experiments, and gating one on the other is deliberate rather than sloppy. + * [BondRefusalGiveUp] does not track the hello; it tracks whether this strap will form an encrypted bond, + * which is the single thing both routes are trying to achieve and neither has. Its threshold is reached on + * the strap's own refusals, so by the time it latches the pairing request has had its five links and + * returned the same answer every time — BOND_NONE, with an HCI capture showing SMP "Pairing Not Supported" + * underneath. That is the experiment concluding, not being cut short. + * + * Every other probe here is bounded and this one was not. The hello has a give-up, the unbonded offload + * probe has a silence budget, and asking Android to pair had nothing at all: it fired once per link, + * forever, on a strap that had definitively said no. The 31 Aug capture caught the consequence — repeated + * system "Pairing rejected" notices, which is worse than a quiet loop because the user has to read them. + * + * `bondGivenUpForDevice` is the PERSISTED suppression latch and not [BondRefusalGiveUp.gaveUp], which + * lives in one process; a bound that dies with the process is the bug this area has now had to fix twice. + * Both things that clear the latch are user actions that could have changed the answer — tapping Connect + * (`clearPairingHintForUserConnect`) and forgetting the device. Putting a 5/MG into pairing mode and + * tapping Connect is the one flow known to have worked on real hardware, and it still asks. */ internal fun shouldRequestExplicitBond( optedIn: Boolean, @@ -39,11 +59,15 @@ internal fun shouldRequestExplicitBond( alreadyBondedAtOsLevel: Boolean, appLevelBonded: Boolean, alreadyRequestedThisLink: Boolean, + bondGivenUpForDevice: Boolean, ): Boolean { if (!optedIn) return false if (!isWhoop5) return false if (alreadyBondedAtOsLevel) return false if (appLevelBonded) return false + // The strap has refused the bond by every route the app has, and Android says so out loud: each + // request it declines surfaces a system "Pairing rejected" notice the user did not ask for. Stop. + if (bondGivenUpForDevice) return false return !alreadyRequestedThisLink } @@ -57,8 +81,36 @@ internal fun shouldRequestExplicitBond( * The hello is left for the NEXT connection. If the pairing succeeds the strap is OS-bonded by then, the * link comes up already encrypted, and the write has a chance to complete for the first time. If it fails * the strap is no worse off than it is today, and the trace says which happened. + * + * That reasoning assumed the pairing might succeed. An HCI capture has since shown it cannot: a 5/MG + * answers every Pairing Request with SMP `Pairing Not Supported` (0x05). So "leave it for the next + * connect" never resolves — the next connect requests a bond too, and defers again. The deferral is + * permanent, which is why neither capture contains a single hello write. + * + * [helloOverride] breaks that cycle. The write-while-pairing hazard it guards against is real, but there + * is no pairing in flight to protect: the refusal arrives in milliseconds, long before the hello. Someone + * who has explicitly opted into "send hello despite bond refusal" is asking for exactly this write, and + * silently swallowing it because a doomed pairing was requested first would make that switch a no-op for + * everyone running the pairing experiment — which is precisely who would turn it on. + * + * [priorDeferrals] ends the cycle for everyone else, and is why the override is no longer the only way + * out. Deferring ONCE is the honest form of the original reasoning: give the pairing the one connect it + * asked for. Deferring again is not caution, it is repeating an experiment whose answer is already in — + * this link has now seen a bond requested and not achieved, so the next connect writes the hello. + * + * The cost of being wrong in each direction is what settles the threshold. Deferring one connect too many + * costs a user their entire history sync, silently and permanently: no hello means no bond, no bond means + * no SET_CLOCK, and an un-clocked 5/MG does not bank to flash at all, so there is nothing to offload even + * once the link is healthy. Writing one hello too early costs, at worst, the link dropping and + * reconnecting — the failure this deferral was added to avoid, which the bond watchdog already handles. + * Field-confirmed on a 5/MG: four days without a sync, thirteen backfill deferrals in one session, and + * not a single hello written since the experiment was enabled. */ -internal fun explicitBondDefersHello(requestedThisLink: Boolean): Boolean = requestedThisLink +internal fun explicitBondDefersHello( + requestedThisLink: Boolean, + helloOverride: Boolean = false, + priorDeferrals: Int = 0, +): Boolean = requestedThisLink && !helloOverride && priorDeferrals < 1 /** * The outcome line when `createBond()` THREW rather than returning. @@ -73,6 +125,27 @@ internal fun explicitBondThrewLine(throwableName: String, bondStateName: String) " This is a local problem (usually a missing Bluetooth permission), NOT the strap refusing" + " (#1635, experimental)" +/** + * The line printed when the pairing request retires, so the log says why the requests stopped. + * + * The give-up lines in this area all exist because something stopped silently and cost weeks of + * unreadable captures. This one has a second job: the user has been watching Android say "Pairing + * rejected", so the log should be able to tell them the app heard it and stopped asking, and what would + * make it ask again. + * + * It claims the RETIREMENT, not a skip — "on this strap again", matching [helloOverrideExhaustedLine]'s + * "stopping" and the offload probe's "Not asking this strap again". It is printed once per process while + * governing every connect after it, so wording it as a one-connect decision would leave a reader looking + * at twenty later connects to conclude the line was stale or the gate was per-connect. Both wrong, and + * both cost more than the two words it takes to be accurate. + */ +internal fun explicitBondGivenUpLine(): String = + "WHOOP 5/MG: not asking Android to pair on this strap again — it has refused the encrypted bond" + + " enough times for the handshake to be latched off, and every further request only produces" + + " another system \"Pairing rejected\" notice. Press Connect to ask again (put the strap in" + + " pairing mode first if you want the pairing itself to have a chance), or turn \"Ask Android to" + + " pair\" off (#1635, experimental)." + /** The outcome line for a `createBond()` call that returned. [initiated] is the API's own return value: * false means the stack refused to even start, which is a different answer from a pairing that starts and * then fails, and the two must not read the same in a capture. A throw is a third answer again — see diff --git a/android/app/src/main/java/com/noop/ble/FrameRejectTally.kt b/android/app/src/main/java/com/noop/ble/FrameRejectTally.kt new file mode 100644 index 0000000000..dae024e738 --- /dev/null +++ b/android/app/src/main/java/com/noop/ble/FrameRejectTally.kt @@ -0,0 +1,143 @@ +package com.noop.ble + +import com.noop.protocol.FrameRejectReason +import com.noop.protocol.ParsedFrame +import com.noop.protocol.wireName + +/** + * Diagnosis for the frame-integrity gate: what a rejected frame still tells us, and how often each + * reason occurred on one connection. Kotlin twin of `FrameDiagnostics.swift` in `WhoopProtocol`. + * + * It lives beside the BLE client rather than in `com.noop.protocol` because the tally is a per- + * connection diagnostic of the link, not a property of the wire format — and because the one thing + * that must stay literally identical across the platforms is what it PRINTS, which is pinned by the + * tests below it, not by where the class sits. + * + * Naming note: Swift spells the named counter `payloadCRCOKButEnvelopeRejected`. This side follows the + * repository's established Kotlin twin casing (`crcOk` for Swift's `crcOK`, as `ParsedFrame` already + * does) for the PROPERTY, while the emitted strings are byte-identical to the Swift ones — the log + * line is the shared artefact a reader compares across two field reports, the identifier is not. + */ + +/** + * Whether the decoder got a PACKET TYPE out of this frame — the parseability question, which is NOT + * the integrity question [ParsedFrame.ok] answers. + * + * The two used to be the same value, so every diagnostic surface that wanted "did this decode?" read + * `ok`. Now `ok` means "header checksum, payload CRC32 and structural length all agree", and reading + * it as parseability would blank the packet type of exactly the frames a capture exists to map: a + * frame with a flipped header byte still decodes its type, its sequence number and its fields, and + * losing that in the record is losing the evidence. + */ +val ParsedFrame.isParsable: Boolean + get() = typeName != UNPARSABLE_TYPE_NAME + +/** The placeholder type name both parsers use for a byte run they could not read at all. */ +const val UNPARSABLE_TYPE_NAME = "INVALID/FRAGMENT" + +/** + * True for the frame class that passed the gates BEFORE this change: the payload CRC32 verified, but + * the envelope did not — a wrong header checksum, a declared length below the family minimum, a + * truncated frame or one with trailing bytes. + * + * Read straight off the parse result; it needs no second verification, because both values are already + * on the frame the consumer was handed. + */ +val ParsedFrame.payloadCrcOkButEnvelopeRejected: Boolean + get() = !ok && crcOk == true + +/** + * Per-reason rejection counters for one connection, plus the ONE extra named counter the hardware run + * is read from. + * + * Why a plain per-reason count is not enough (D3): the abort criterion for the hardware run is a + * CONJUNCTION — "header checksum or length wrong WHILE the payload CRC32 verifies" — and no single + * reason bucket can express it. The length bucket in particular also collects the harmless resyncs + * after a lost notification, which were happening before this change too. So + * [payloadCrcOkButEnvelopeRejected] is counted separately; it is the class that used to pass, and thus + * the cleanest regression signal. + * + * Counted per reason only — NOT additionally per device family. A connection talks to exactly one + * strap, so a family dimension would be constant. + * + * A tally asserts only what it observed: an unverifiable payload CRC32 is counted as + * [FrameRejectReason.PAYLOAD_CRC_UNVERIFIABLE], never folded into + * [FrameRejectReason.PAYLOAD_CRC_MISMATCH]. "Could not be checked" is not "was wrong". + * + * Not thread-safe on its own: the BLE client mutates it only from the GATT binder thread that feeds + * the reassembler, and reads it on the same thread at teardown. + */ +class FrameRejectTally { + + private val counts = HashMap() + + /** The named class from D3: envelope rejected while the payload CRC32 verified. */ + var payloadCrcOkButEnvelopeRejected = 0 + private set + + /** + * The reassembler's monotonic drop count already folded into [FrameRejectReason.BELOW_MINIMUM_LENGTH], + * so repeated folds of the same counter cannot double-count. + */ + private var absorbedReassemblerDrops = 0 + + /** + * Count one parse result. Intact frames are ignored, so this can sit on the frame path unguarded. + * Returns the reason recorded, or [FrameRejectReason.NONE] when the frame was intact and nothing + * was counted. + */ + fun note(parsed: ParsedFrame): FrameRejectReason { + if (parsed.ok) return FrameRejectReason.NONE + val reason = parsed.rejectReason + counts[reason] = (counts[reason] ?: 0) + 1 + if (parsed.payloadCrcOkButEnvelopeRejected) payloadCrcOkButEnvelopeRejected += 1 + return reason + } + + /** + * Fold a reassembler's [Reassembler.belowMinimumLengthDrops] into the + * [FrameRejectReason.BELOW_MINIMUM_LENGTH] bucket. + * + * A byte run the reassembler drops never reaches a parser — and never reaches the evidence- + * preserving reader either — so without this it would vanish with no trace at all. The argument is + * the reassembler's MONOTONIC total; only the growth since the last fold is added, so calling this + * once per notification is correct and idempotent. + */ + fun absorbReassemblerDrops(monotonicTotal: Int) { + if (monotonicTotal <= absorbedReassemblerDrops) return + val reason = FrameRejectReason.BELOW_MINIMUM_LENGTH + counts[reason] = (counts[reason] ?: 0) + (monotonicTotal - absorbedReassemblerDrops) + absorbedReassemblerDrops = monotonicTotal + } + + /** How often [reason] was recorded. */ + fun count(reason: FrameRejectReason): Int = counts[reason] ?: 0 + + /** Every rejection counted, across all reasons. */ + val totalRejected: Int + get() = counts.values.sum() + + /** + * One line naming every reason that actually occurred, in a stable order, or null when nothing was + * rejected — a per-connection readout, so its caller gates it behind the Test Centre domain. + * Silence when there is nothing to report; no "0 rejections" line to read past. + * + * Byte-identical in shape to the Swift `FrameRejectTally.summaryLine()`, including the reasons' + * wire spellings, so the two platforms' strap logs can be read with one eye. + */ + fun summaryLine(): String? { + if (totalRejected == 0) return null + val parts = FrameRejectReason.entries + .filter { it != FrameRejectReason.NONE && count(it) > 0 } + .joinToString(" ") { "${it.wireName}=${count(it)}" } + return "frameReject total=$totalRejected $parts" + + " payloadCRCOKButEnvelopeRejected=$payloadCrcOkButEnvelopeRejected" + } + + /** Drop every count, for a fresh connection. */ + fun reset() { + counts.clear() + payloadCrcOkButEnvelopeRejected = 0 + absorbedReassemblerDrops = 0 + } +} diff --git a/android/app/src/main/java/com/noop/ble/FtmsSource.kt b/android/app/src/main/java/com/noop/ble/FtmsSource.kt index 4b9436b130..0ca48e6315 100644 --- a/android/app/src/main/java/com/noop/ble/FtmsSource.kt +++ b/android/app/src/main/java/com/noop/ble/FtmsSource.kt @@ -169,7 +169,7 @@ class FtmsSource( val firstSight = seen.put(address, device) == null val name = result.scanRecord?.deviceName ?: runCatching { device.name }.getOrNull() ?: "Gym Equipment" - if (firstSight) log("FTMS: found $name ($address) rssi ${result.rssi}") + if (firstSight) log("FTMS: found ${logSafeDeviceName(name)} ($address) rssi ${result.rssi}") val machine = DiscoveredMachine(address = address, name = name, rssi = result.rssi) val list = _discovered.value.toMutableList() val i = list.indexOfFirst { it.address == address } diff --git a/android/app/src/main/java/com/noop/ble/GattCapability.kt b/android/app/src/main/java/com/noop/ble/GattCapability.kt index d57a432f03..20f3384d33 100644 --- a/android/app/src/main/java/com/noop/ble/GattCapability.kt +++ b/android/app/src/main/java/com/noop/ble/GattCapability.kt @@ -84,3 +84,88 @@ internal fun gattTreeLines(services: List>>> } } } + +/** + * One puffin NOTIFY characteristic as the pairing dump sees it. + * + * Deliberately NO "subscribed" field. This dump is emitted at service discovery, before the CCCD queue is + * drained, so any subscription state read here is "not yet" by construction rather than by fact — and on + * API 33+ it could not be read anyway, since `writeDescriptor(descriptor, value)` never populates + * `descriptor.value`. Which characteristics actually subscribed is already in the log, one confirmed + * `Subscribed ` line each, written where the confirmation arrives. + */ +internal data class NotifyCharDump( + val uuid: String, + val hasCccd: Boolean, +) + +/** + * The link's PAIRING posture, next to the puffin notify chars and whether each carries a CCCD. + * + * [gattTreeLines] says what the strap OFFERS; this says what we hold with it. On a 5/MG the two together + * are the difference between "the chars are not there" and "the chars are there and we never subscribed + * them", which a log otherwise cannot distinguish — the capture that prompted this showed all four puffin + * notify chars discovered, one standard-HR subscribe, and no further word (#1949). + * + * It reports POSTURE, not outcome: this runs at service discovery, before the CCCD queue is drained, so + * every subscription is still ahead of it. The outcomes are the `Subscribed ` lines that follow, and + * what their ABSENCE means flips with [probeOptedIn] — ours when the probe is off, the strap's when it is + * on and subscribing those chars itself. The note says which, because guessing wrong there inverts the + * single distinction this dump exists to draw. + * + * Same discipline as its sibling: reads already-known local state, sends nothing, and so works on exactly + * the strap that no puffin probe can reach. + * + * NO Swift twin, and this file's Swift half already says why in general: `gattTreeLines` was twinned + * because CoreBluetooth exposes services, characteristics and properties, "unlike the bond-state and + * pairing helpers, which have no Apple equivalent at all". This is one of those — it is keyed on the OS + * bond state, which CoreBluetooth does not publish, and on an opt-in for a probe that is Android-only. + * Worth recording for whoever wants the iOS equivalent: the asymmetry there runs the other way, since + * `CBCharacteristic.isNotifying` is authoritative, so iOS could carry the subscription column Android + * cannot — after the subscribe attempt, not at discovery. + * + * On encryption it deliberately claims LESS than a reader might want. Android publishes no + * link-encryption flag to a GATT client, and a remote characteristic's permissions read back as 0, so the + * bond state is the only standing proxy and it is not the same question. The hard evidence is a CCCD + * write status, which is why the note names the two codes that answer it (#1635). + */ +internal fun whoop5PairingDumpLines( + bondState: Int, + didBond: Boolean, + helloWrittenThisLink: Boolean, + probeOptedIn: Boolean, + notifyChars: List, +): List = buildList { + // [bondStateName] is BondStateTrace's, deliberately: two spellings of one OS state in one log is + // how a reader ends up believing they are two different readings. + add( + "pairing: bond=${bondStateName(bondState)} didBond=$didBond" + + " helloWritten=$helloWrittenThisLink unbondedProbe=${if (probeOptedIn) "on" else "off"}" + ) + if (notifyChars.isEmpty()) { + add(" no puffin notify characteristics discovered") + } else { + for (c in notifyChars) { + add(" ${c.uuid} cccd=${if (c.hasCccd) "yes" else "no"}") + } + } + // Which way this reads FLIPS with the opt-in, and getting it wrong would invert the one distinction + // the dump exists to draw. With the probe off, only the standard HR and battery chars are ever queued, + // so an unsubscribed puffin char is ours. With it ON, the probe subscribes those same chars itself, so + // a missing line there is the strap's answer and not a setting. + add( + if (probeOptedIn) { + " note: the unbonded probe is ON and subscribes the puffin chars itself, so one above with no" + + " later \"Subscribed\" line is the STRAP's answer, logged with the status it refused on." + } else { + " note: only the standard HR and battery chars are queued for subscription on a 5/MG, so a" + + " puffin char above with no later \"Subscribed\" line went unsubscribed by OUR choice," + + " not the strap's." + } + ) + add( + " note: Android publishes no link-encryption flag to a GATT client and a remote characteristic's" + + " permissions read back as 0, so bond= above is a proxy, not the answer. The hard evidence is a" + + " CCCD write returning status 5 (insufficient authentication) or 15 (insufficient encryption)." + ) +} diff --git a/android/app/src/main/java/com/noop/ble/HelloSuppression.kt b/android/app/src/main/java/com/noop/ble/HelloSuppression.kt index 372955a7d1..cfcccfb922 100644 --- a/android/app/src/main/java/com/noop/ble/HelloSuppression.kt +++ b/android/app/src/main/java/com/noop/ble/HelloSuppression.kt @@ -6,9 +6,16 @@ package com.noop.ble * The #1635 field captures show the hello is what ends the link. Across two sessions and sixteen writes it * was never once acknowledged, and the drop is locked to the HELLO rather than to the connect: 3.158s, * 3.159s, 3.155s after the write, cycle after cycle, while live HR streams happily over the standard - * profile the whole time. The bond-state trace added for this (#1639) records no OS pairing attempt at all, - * so the strap is not refusing a pairing — the write simply never completes and the local stack drops the - * ACL, which the log reports as an unattributed GATT status 22. + * profile the whole time. The bond-state trace added for this (#1639) records no OS pairing attempt at all + * — the write simply never completes and the local stack drops the ACL, which the log reports as an + * unattributed GATT status 22. + * + * That trace was later read as "the strap is not refusing a pairing". An HCI capture disproves it: the + * phone DOES transmit an SMP Pairing Request and the strap answers `Pairing Not Supported` (0x05). The + * refusal is real, and it is categorical — the encrypted bond this hello was waiting behind can never + * arrive on a 5/MG. That does not invalidate the suppression, whose evidence is about the hello's own + * fate, but it does mean suppressing it leaves the app attempting NEITHER handshake. Hence the opt-in + * override below. * * The result is a strap that CAN deliver live HR being knocked off every five seconds by a handshake that * has never once succeeded. Suppressing the hello after the give-up latches trades an unreachable @@ -17,8 +24,11 @@ package com.noop.ble * never at all. * * [userInitiated] always re-attempts: suppression is a fallback for automatic reconnects, never a - * permanent verdict. Someone who puts the strap in pairing mode and presses Connect must get a fresh try, - * and that is also how the suppression is cleared. + * permanent verdict. Someone who puts the strap in pairing mode and presses Connect must get a fresh try. + * That try is ONE connect's worth and nothing more — the latch itself survives the tap, so if the strap + * still will not answer, the automatic reconnects behind it stay suppressed instead of re-earning the + * give-up over five more refusals. The latch is dropped by a genuine bond (it demonstrably works now) or + * by forgetting the strap, which are the two events that are actually evidence about this device. * * Deliberately NOT re-armed by "an OS pairing exists". That looks right — a pairing is new evidence the * handshake might work — but the condition never goes away, so a strap that pairs and STILL will not @@ -31,7 +41,148 @@ package com.noop.ble internal fun shouldSendClientHello( suppressedForDevice: Boolean, userInitiated: Boolean, -): Boolean = !suppressedForDevice || userInitiated + overrideSuppression: Boolean = false, +): Boolean = !suppressedForDevice || userInitiated || overrideSuppression + +/** + * How many times the #1635 override may write an unanswered hello before it stops on its own. + * + * The override needs a bound that does not depend on the disconnect status, and the shared give-up cannot + * supply one. `shouldCountNeverBondedSelfDrop` excludes `GATT_CONN_TERMINATE_LOCAL_HOST` (0x16) because + * that normally means WE hung up — our own `gatt.disconnect()` or the bond-watchdog bounce — and counting + * those would be self-referential. But the hello failure arrives as exactly that status: the strap answers + * the write with ATT `Insufficient Authentication`, the local stack tries to elevate security, SMP is + * refused, and the stack tears the ACL down. Not our teardown, same status code. + * + * Field result of leaving it unbounded: 57 reconnect cycles in an hour, each ~4.8s, with nothing able to + * stop it. So the cap lives here instead — small enough to spare the battery, large enough that a strap + * which answers on a later attempt is not written off. + */ +internal const val HELLO_OVERRIDE_MAX_ATTEMPTS = 6 + +/** + * May the override write another hello, given how many it has already written unanswered? + * + * Counted per app process and reset on a genuine bond. A process restart resets it too, which is the + * honest limit of an in-memory bound — but the loop it exists to stop runs within one process, and a + * restart is not the failure mode. + */ +internal fun overrideHelloStillAllowed( + attemptsSoFar: Int, + cap: Int = HELLO_OVERRIDE_MAX_ATTEMPTS, +): Boolean = attemptsSoFar < cap + +/** + * Is the override actually in force — opted in AND with budget left? + * + * Every reader must ask through here rather than reading the pref directly. A spent override is not + * "on": the hello genuinely stops going out, and a caller still reading the raw pref believes a hello + * is on the wire that is not. That is the `didBond`-reader trap from CLAUDE.md pointed the other way, + * and it had already reached [shouldCountNeverBondedSelfDrop]: with the budget spent, the app is in the + * deliberate suppressed-hello live-HR state, but the raw pref reported the hello as still being sent, so + * the never-bonded detector kept counting self-drops and would eventually pause auto-reconnect and raise + * the stale-pairing guide — telling the user to re-pair for a cause that never happened. + * + * The boundary is deliberate: the link carrying the LAST permitted hello reads as inactive at its own + * disconnect, because [helloOverrideAttempts] is charged when that hello is written. Under-counting that + * one drop is the wanted behaviour — no further hello follows it, so the loop is already over, and + * counting it could trip the pause at the exact moment the override retires. + */ +internal fun helloOverrideActive( + optedIn: Boolean, + attemptsSoFar: Int, + cap: Int = HELLO_OVERRIDE_MAX_ATTEMPTS, +): Boolean = optedIn && overrideHelloStillAllowed(attemptsSoFar, cap) + +/** + * Does flipping the switch to on re-arm a spent budget? + * + * Turning the experiment off and on again is the user explicitly asking for another try, and without + * this they would not get one: the counter outlives the toggle, so re-enabling a spent override does + * nothing whatsoever — and silently, because the give-up line is one-shot and has already latched. The + * edge is sampled per connect, which is the only moment the override can act anyway. + */ +internal fun helloOverrideBudgetRearms(optedInNow: Boolean, optedInLastSeen: Boolean): Boolean = + optedInNow && !optedInLastSeen + +/** + * The line printed when the override gives up, so the log says why the hello stopped rather than leaving + * a reader to notice its absence. + */ +internal fun helloOverrideExhaustedLine(attempts: Int): String = + "WHOOP 5/MG: \"send hello despite bond refusal\" has written $attempts unanswered hellos — stopping." + + " The strap answers the write with Insufficient Authentication and refuses SMP pairing, so the" + + " handshake cannot complete; continuing would only loop the link. Turn the switch off to return to" + + " the stable live-HR state (#1635)." + +/** + * Should an explicit Connect keep the link it already holds, instead of rebuilding it? + * + * `getConnectedWhoopDevice()` answers "the OS has this device connected", which is true of our own live + * GATT too — so the Easy-connect path re-attached over a link that was already working. Field log + * 260901-0121: a link up 18m 32s and streaming was replaced on a tap. + * + * Every clause is load-bearing: + * - [genuinelyBonded]: the ONLY state where a reconnect can achieve nothing. A suppressed strap is + * deliberately excluded — there the tap IS the handshake retry, and the hello can only be written on a + * new link's discovery, so it has to reconnect. + * - [sameDevice]: a tap meant for a different strap must not be swallowed by the one in hand. + * - [silentMs] < [stallFuseMs]: `connected` alone is not evidence the link WORKS. A silently dead GATT + * reports connected until the watchdog bounces it, and that is exactly when someone taps Connect — + * keeping the link there would make the button inert in the one state it exists for. The fuse is the + * watchdog's own, so the two can never disagree about whether this link is alive. + */ +internal fun connectKeepsExistingLink( + genuinelyBonded: Boolean, + connected: Boolean, + sameDevice: Boolean, + silentMs: Long, + stallFuseMs: Long, +): Boolean = genuinelyBonded && connected && sameDevice && silentMs < stallFuseMs + +/** + * How many consecutive refusals this cause needs before the give-up latches. + * + * The flat 5 was argued for the AUTH-REFUSAL branch and only makes sense there: the pairing hint shows at + * 2, the hint asks the user to do something (close the official app, free a stale phone pairing), and the + * extra cycles are the time to do it before NOOP stops hammering. + * + * An unanswered handshake gives the user nothing to act on. The write vanishes, the strap is not refusing + * anything it could be talked out of, and the outcome — suppress the hello and keep streaming live HR — + * needs no permission and costs no capability that was reachable anyway. Every cycle spent waiting is a + * ~4.8s link drop bought for nothing, so this branch stops at 3. + * + * Not 2, which is exactly where the pairing hint fires: a strap whose hello is unanswered twice by some + * transient would latch a PERSISTED verdict with no margin at all. 3 keeps one cycle of margin and still + * takes roughly 40% off the churn. + */ +internal const val UNANSWERED_GIVE_UP_THRESHOLD = 3 + +/** + * The give-up threshold for the refusal in hand. Keyed on the same [authRefusal] that decides whether the + * give-up suppresses or pauses ([giveUpSuppressesHello]), so the two can never disagree about which branch + * a refusal belongs to. + */ +internal fun giveUpThresholdFor(authRefusal: Boolean, pauseThreshold: Int): Int = + if (authRefusal) pauseThreshold else UNANSWERED_GIVE_UP_THRESHOLD + +/** + * May a pairing-hint clear also drop the PERSISTED hello-suppression latch? + * + * Only a genuine bond. A bond is proof the handshake works on this strap NOW, so the old verdict is stale + * and must go. A user Connect is not proof of anything: it already gets its one fresh attempt from the + * retry flag ([shouldSendClientHello]'s `userInitiated`), and dropping the latch on top of that un- + * suppressed every AUTOMATIC reconnect behind the attempt — so a single tap cost five more refusals and + * ~55s of link churn to re-earn a verdict about firmware that had not changed. Field log 260901-0121: + * latched 01:02:31, stable for 18 minutes, one tap at 01:21:09, straight back into the loop. + * + * Forgetting the strap clears it too, but writes the pref directly — the strap is being released, so there + * is no hint left to clear and nothing to route through here. + * + * Apple has only ever cleared the latch on a genuine bond or a forget; this predicate is what makes the + * Android side say the same thing. + */ +internal fun pairingHintClearDropsSuppressionLatch(genuineBond: Boolean): Boolean = genuineBond /** * Should the give-up latch suppress the hello, rather than pause auto-reconnect? diff --git a/android/app/src/main/java/com/noop/ble/HuamiHrSource.kt b/android/app/src/main/java/com/noop/ble/HuamiHrSource.kt index 737c6815f7..735305accb 100644 --- a/android/app/src/main/java/com/noop/ble/HuamiHrSource.kt +++ b/android/app/src/main/java/com/noop/ble/HuamiHrSource.kt @@ -222,7 +222,7 @@ class HuamiHrSource( val brand = ExperimentalBrand.recognise(name) if (brand != ExperimentalBrand.AMAZFIT && brand != ExperimentalBrand.MI_BAND) return val firstSight = seen.put(address, device) == null - if (firstSight) log("Huami: found $name ($address) rssi ${result.rssi}") + if (firstSight) log("Huami: found ${logSafeDeviceName(name)} ($address) rssi ${result.rssi}") val dev = DiscoveredDevice( address = address, name = name.ifBlank { brand.displayBrand }, diff --git a/android/app/src/main/java/com/noop/ble/LastSyncAttribution.kt b/android/app/src/main/java/com/noop/ble/LastSyncAttribution.kt new file mode 100644 index 0000000000..d1369b98e1 --- /dev/null +++ b/android/app/src/main/java/com/noop/ble/LastSyncAttribution.kt @@ -0,0 +1,68 @@ +package com.noop.ble + +/** + * Which "last sync" timestamp belongs to a strap, given what is known about it. + * + * The same bug [resolveFirmware] fixed for firmware, in the field again and reported three times before + * it was believed. `noop.lastSyncAtSec` is ONE global key, stamped on any strap's HISTORY_COMPLETE and + * read back for whichever strap is active. On a single-strap install those are the same strap, which is + * why it went unnoticed. With two paired it reports the OTHER strap's sync: a capture showing + * "Last sync: 4d ago" beside `Days: whoop-MGB…=0` and a 4.0 last seen 3 days earlier — the 5/MG had never + * banked a single row, and the number on its screen was the 4.0's. + * + * Worse than merely wrong: it is wrong in the reassuring direction. A strap that has never synced reads + * as recently synced, and the pull-to-refresh that cannot possibly change it looks broken rather than + * inapplicable. It sent this investigation after a "sync regression" that never existed. + * + * The rule, in order: + * - [perDevice] wins: this strap's own stamp, from its own completed offload. + * - [legacyGlobal] ONLY when [pairedCount] is 1. The old key cannot say which strap it belongs to, so + * it is trustworthy exactly when there is only one strap it could have come from — which keeps a + * single-strap install reading correctly across the upgrade instead of resetting to "never". + * - otherwise null, meaning "this strap has never synced". For the strap in the capture that is not a + * degraded answer, it is the correct one, and it is what the whole thread was missing. + * + * Deliberately NOT migrated by writing the global onto the active device at upgrade. That is the same + * guess in a different place, and on a multi-strap install it would bake the wrong attribution in + * permanently instead of leaving it to self-correct at the next real sync. + * + * Pure so the attribution is unit-tested without prefs, a strap, or a registry. + */ +internal fun resolveLastSync( + perDevice: Long, + legacyGlobal: Long, + pairedCount: Int, +): Long? = perDevice.takeIf { it > 0L } + ?: legacyGlobal.takeIf { it > 0L && pairedCount == 1 } + +/** + * The per-device preference key for a persisted last-sync timestamp. + * + * Keyed on the BLE peripheral address for the same reason [firmwarePrefKey] is: HISTORY_COMPLETE arrives + * on a connection whose address is known, and resolving it to a registry id there would repeat the + * mis-mapping #1527 fixed for `lastSeen` — stamping "the active row" records a sync for a strap that was + * never connected, which is precisely the class of error being removed here. + * + * Lowercased, because the same strap presents its address in different cases across sessions and a + * case-sensitive key would strand the earlier stamp under a second name. Returns null for a blank + * address, so a caller with no address writes nothing rather than writing to a key that belongs to no + * device. + */ +internal fun lastSyncPrefKey(peripheralId: String?): String? = + peripheralId?.trim()?.takeIf { it.isNotEmpty() }?.let { "noop.lastSyncAt.${it.lowercase()}" } + +/** + * The per-device preference key for the #57 write-health stamps ("rows last landed", "offload stalled"). + * + * Same defect as [lastSyncPrefKey] and found in the same capture, one line below it: `Data write: rows + * last landed 4d ago` printed against a strap whose own row count was zero, because the stamp was global + * and the 4.0 had written it. Two lines that both looked like evidence the 5/MG had been syncing, and + * neither was about the 5/MG. + * + * [kind] separates "ok" from "stalled" so both keep the per-device scoping rather than only the one that + * happened to be noticed — they are read as a PAIR ("stalled more recently than ok" is the alarm), and + * scoping one without the other would compare a strap's own stall against another strap's success. + */ +internal fun writeHealthPrefKey(peripheralId: String?, kind: String): String? = + peripheralId?.trim()?.takeIf { it.isNotEmpty() } + ?.let { "sync.$kind.${it.lowercase()}" } diff --git a/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt b/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt index a0da2a59ea..988bd20896 100644 --- a/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt +++ b/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt @@ -51,6 +51,32 @@ import java.security.SecureRandom import java.util.UUID import java.util.concurrent.ConcurrentHashMap +/** + * The label a discovered ring is listed under: its advertised local name, or [fallback] when it did not + * advertise one. + * + * A BONDED ring routinely stops advertising a local name, so the blank case is the NORMAL case for the + * ring a user most wants to reconnect to (#1776), not an error path - the scan's ScanFilter is the only + * gate and the name is a label. Callers therefore never see a blank `DiscoveredRing.name`, which is why + * the wizard renders it unguarded on both platforms (#1783 reads that as a missing guard; the guard is + * here, at the single construction site). + * + * BLANK, not empty. `isBlank` treats an all-whitespace name as absent; the Swift twin + * `ouraAdvertisedLabel` in `Strand/BLE/OuraLiveSource.swift` used `isEmpty`, which does not, so a ring + * advertising `" "` listed as "Oura" here and blank there - the exact silent Kotlin/Swift divergence the + * parity contract calls out. Both sides now treat whitespace as absent and return the name UNTRIMMED + * when it is present, and `OuraNamelessRingDiscoveryTest` pins this function against the Swift twin's + * own output. The two agree over ASCII whitespace, ordinary names, and U+00A0. `ifBlank` does NOT test + * `Character.isWhitespace`, which would indeed exclude U+00A0; it tests Kotlin's `Char.isWhitespace`, + * defined as `Character.isWhitespace(c) || Character.isSpaceChar(c)`, and the second disjunct restores + * every non-breaking space the first drops. The pair parts only on U+0085 (blank on Apple, not here) + * and U+001C-U+001F (blank here, not on Apple), which no BLE local name carries - stated rather than + * silently overclaimed. + */ +internal fun ouraAdvertisedLabel(advertisedName: String, fallback: String): String = + advertisedName.ifBlank { fallback } + + /** * EXPERIMENTAL, ISOLATED live-BLE source for the Oura ring (gen3 / gen4 / gen5). * @@ -285,6 +311,14 @@ class OuraLiveSource( private var loggedFirstTemp = false /** Logs the FIRST SpO2 sample decoded this session only. Twin of [loggedFirstTemp]. */ private var loggedFirstSpo2 = false + /** The 0x13 SyncTime reply parked because nothing yet available could disambiguate its unit (ticks vs + * seconds x10): the resume cursor was 0 (fresh pair / post-reboot full pull) or so stale the ring's + * clock had run past the window. Retried against the drain's maxSeenRingTime as the first batch lands + * (2026-09-02/03 captures). The ORIGINAL receipt wall-clock is carried along, because THAT is the instant the ring's + * counter pairs with - re-stamping at retry time would skew the anchor by the drain's latency. + * Twin of Swift's pendingSyncTime. */ + private var pendingSyncTime: Triple? = null + /** Logs the FIRST ring-time -> UTC anchor of this session only (s5.5); reset on stop/disconnect. */ private var loggedAnchor = false /** Tier-B (UNVERIFIED) kinds ("activity" / "real_steps" / "sleep_summary" / "spo2_smoothed") already @@ -808,28 +842,72 @@ class OuraLiveSource( pendingUnanchoredBursts.clear() } + /** + * The ring-time floor the 0x13 unit test disambiguates against: the persisted resume cursor, or the + * largest envelope ring-time this drain has seen when that is further along. maxSeenRingTime is the + * half that breaks the deadlock (2026-09-02/03 captures) — it counts EVERY history record, anchored or not, so it is + * readable with no anchor, whereas the cursor can only advance once an anchor exists. + * Twin of Swift's syncTimeAnchorLowerBound. + */ + private val syncTimeAnchorLowerBound: Long + get() = maxOf(historyCursor, drain.maxSeenRingTime) + + /** + * Try to turn a 0x13 SyncTime reply into this session's UTC anchor, returning whether it stuck. + * [receivedAt] is the host wall-clock at the moment the reply ARRIVED (not now), since that is the + * instant the ring's counter pairs with. Shared by the at-connect attempt and the retry. + * Twin of Swift's adoptSyncTimeAnchor. + */ + private fun adoptSyncTimeAnchor( + d: OuraDriver, + deviceTimestamp: Long, + status: Int, + receivedAt: Long, + source: String, + ): Boolean { + val rt = OuraDriver.syncTimeAnchorCandidate(deviceTimestamp, syncTimeAnchorLowerBound) + ?: return false + if (!d.adoptSyncTimeAnchor(ringTimestamp = rt, unixSeconds = receivedAt)) return false + val unit = if (rt == deviceTimestamp) "ticks" else "seconds x10" + val raw = "0x%08x".format(deviceTimestamp) + if (!loggedAnchor) { + loggedAnchor = true + log("Oura: UTC anchor from SyncTime response (0x13) $source - device rt $rt [$unit, " + + "raw $raw, status $status] = its receipt time; no 0x42 needed this session") + } + drainPendingAnchorEvents() + drainPendingHypnogramBursts() + return true + } + /** * Anchor from the 0x13 SyncTime response (ringverse: the ring's clock counter when it processed - * our SyncTime, paired with host wall-clock at receipt). The tick unit is disambiguated against - * the persisted resume cursor; no unambiguous reading → log the raw value and adopt NOTHING (an - * honest missing anchor beats a guessed one). Kotlin twin of Swift's handleSyncTimeResponse. + * our SyncTime, paired with host wall-clock at receipt). The tick unit is disambiguated against a + * known-earlier ring-time. At connect the only reference is the resume cursor, which is 0 on a fresh + * pair and may be far staler than the ring's clock; rather than discard the reply, PARK it and retry + * once history starts landing (2026-09-02/03 captures). Still adopts NOTHING on ambiguity — an honest missing anchor + * beats a guessed one. Kotlin twin of Swift's handleSyncTimeResponse. */ private fun handleSyncTimeResponse(d: OuraDriver, resp: com.noop.oura.SyncTimeResponse) { val now = System.currentTimeMillis() / 1000L + if (adoptSyncTimeAnchor(d, resp.deviceTimestamp, resp.status, now, "at connect")) return + pendingSyncTime = Triple(resp.deviceTimestamp, resp.status, now) val raw = "0x%08x".format(resp.deviceTimestamp) - val rt = OuraDriver.syncTimeAnchorCandidate(resp.deviceTimestamp, historyCursor) - if (rt != null && d.adoptSyncTimeAnchor(ringTimestamp = rt, unixSeconds = now)) { - val unit = if (rt == resp.deviceTimestamp) "ticks" else "seconds x10" - if (!loggedAnchor) { - loggedAnchor = true - log("Oura: UTC anchor from SyncTime response (0x13) - device rt $rt [$unit, raw $raw, " + - "status ${resp.status}] = now; no 0x42 needed this session") - } - drainPendingAnchorEvents() - drainPendingHypnogramBursts() - } else { - log("Oura: SyncTime response (0x13) raw $raw status ${resp.status} - no unambiguous tick " + - "reading vs cursor $historyCursor; anchor NOT adopted (investigation)") + log("Oura: SyncTime response (0x13) raw $raw status ${resp.status} - no unambiguous tick " + + "reading vs ring-time floor $syncTimeAnchorLowerBound; parked, retrying as history lands " + + "(investigation)") + } + + /** + * Retry a parked 0x13 reply now that the drain has seen real ring-times. Silent on failure (the + * parked reply simply waits for a better floor); one line on success, from [adoptSyncTimeAnchor]. + * Twin of Swift's retryPendingSyncTimeAnchor. + */ + private fun retryPendingSyncTimeAnchor(d: OuraDriver) { + val parked = pendingSyncTime ?: return + if (drain.maxSeenRingTime <= 0) return + if (adoptSyncTimeAnchor(d, parked.first, parked.second, parked.third, "resolved against history")) { + pendingSyncTime = null } } @@ -870,8 +948,9 @@ class OuraLiveSource( log("Oura: Bluetooth adapter is off - cannot scan") return } - // Filter by the ring's base service so a broad scan does not surface unrelated peripherals; the - // callback further confirms the advertised name reads as an Oura ring. + // Filter by the ring's base service so a broad scan does not surface unrelated peripherals. This + // ScanFilter is the ONLY discovery gate — the callback does not re-filter on the advertised name, + // because a bonded ring often advertises none; see the note in `scanCallback`. val filter = ScanFilter.Builder() .setServiceUuid(ParcelUuid(SERVICE_UUID)) .build() @@ -927,6 +1006,7 @@ class OuraLiveSource( loggedFirstTemp = false loggedFirstSpo2 = false loggedAnchor = false + pendingSyncTime = null loggedTierBKinds.clear() loggedFeatureStatuses.clear() loggedProductInfo.clear() @@ -1019,6 +1099,7 @@ class OuraLiveSource( loggedFirstTemp = false loggedFirstSpo2 = false loggedAnchor = false + pendingSyncTime = null loggedTierBKinds.clear() loggedFeatureStatuses.clear() loggedProductInfo.clear() @@ -1099,16 +1180,21 @@ class OuraLiveSource( val device = result.device ?: return val address = device.address ?: return val name = result.scanRecord?.deviceName ?: runCatching { device.name }.getOrNull() ?: "" - // Confirm the advertised name reads as an Oura ring (the service filter is the primary gate; - // this rejects anything that slipped through advertising the same base service). - if (ExperimentalBrand.recognise(name) != ExperimentalBrand.OURA) return val firstSight = seen.put(address, device) == null // null → not seen before this scan - if (firstSight) log("Oura: found $name ($address) rssi ${result.rssi}") - // Best-effort generation guess from the advertised name (confirmed by the model the user picks). + if (firstSight) log("Oura: found ${ouraAdvertisedLabel(name, "Oura ring")} ($address) rssi ${result.rssi}") + // Best-effort generation guess from the advertised name — a LABEL only (the wizard falls back + // to GEN3 when it is null). Nothing is dropped here: startScan's ScanFilter pins SERVICE_UUID in + // the BT stack, so that is the gate, and a ring is listed even when it advertises no local name + // at all. This callback USED TO drop a nameless device (`ExperimentalBrand.recognise(name) != + // OURA`, and `recognise("")` is null because no brand token is a substring of ""), so a BONDED + // ring — which often stops advertising a local name, and whose Android `device.name` bond-cache + // fallback is empty when the bond lives elsewhere — was the one ring the wizard could not show. + // Twin of the Apple side (Strand/BLE/OuraLiveSource.swift), which has always listed on the + // service filter alone. Do not re-add a name-based drop here. val detectedGen = OuraRingGen.recognise(name) val ring = DiscoveredRing( address = address, - name = name.ifBlank { "Oura" }, + name = ouraAdvertisedLabel(name, "Oura"), rssi = result.rssi, detectedGen = detectedGen, ) @@ -1159,6 +1245,7 @@ class OuraLiveSource( loggedFirstTemp = false loggedFirstSpo2 = false loggedAnchor = false + pendingSyncTime = null loggedTierBKinds.clear() loggedFeatureStatuses.clear() loggedProductInfo.clear() @@ -1542,6 +1629,10 @@ class OuraLiveSource( for (e in events) { e.envelopeRingTimestamp?.let { drain.noteSeenRingTime(it) } } + // The drain now has a ring-time reference that needs no anchor, so a 0x13 reply parked at + // connect may be resolvable. Retry BEFORE emit, so this batch anchors straight away + // instead of parking into pendingAnchorEvents and being drained a moment later (2026-09-02/03 captures). + retryPendingSyncTimeAnchor(d) if (pendingContinuation && events.isNotEmpty()) { handler.removeCallbacks(batchQuietRunnable) handler.postDelayed(batchQuietRunnable, batchQuietMs) @@ -1809,8 +1900,10 @@ class OuraLiveSource( // Other Tier-B tags (real_steps / activity-summary / sleep-summary / smoothed-SpO2, // OURA_PROTOCOL.md s7.3; PR #960): logged ONCE PER KIND with the raw bytes so we can // see whether the ring sends these tags at all and collect capture material - e.g. - // real_steps 0x7E/0x7F is server-flag-gated OFF by default ([open_oura-feat]), so its - // continued absence here is the ring's doing, not a decode gap. + // real_steps 0x7E/0x7F was assumed server-flag-gated OFF ([open_oura-feat]), which + // made its absence self-explanatory. That no longer holds: the ring's own status read + // came back ENABLED on-device (#1629), so gating does NOT explain why these tags never + // arrive, and whether that is the ring's doing or a decode gap is currently unknown. if (loggedTierBKinds.add(e.value.kind)) { val hex = e.value.rawPayload.joinToString(" ") { "%02x".format(it) } log("Oura: Tier-B ${e.value.kind} seen (tag 0x${e.value.tag.toString(16)}) - raw: $hex") diff --git a/android/app/src/main/java/com/noop/ble/PuffinDeepBufferLog.kt b/android/app/src/main/java/com/noop/ble/PuffinDeepBufferLog.kt index b55f51a949..510e9de21f 100644 --- a/android/app/src/main/java/com/noop/ble/PuffinDeepBufferLog.kt +++ b/android/app/src/main/java/com/noop/ble/PuffinDeepBufferLog.kt @@ -11,8 +11,8 @@ import com.noop.protocol.Whoop5RawImu * `PuffinDeepBufferLog` (its pure, unit-testable core); the file I/O + capture-toggle gating live in * [WhoopBleClient.writeWhoop5DeepBufferIfBig], mirroring the existing `whoop5-events.jsonl` writer. * - * On-strap probing (#423) established that the 5/MG has no live raw-IMU stream, but it DOES bank - * high-rate motion and ships it inside big type-0x2F buffers during the connect-time offload burst — + * On-strap probing (#423) established that the 5/MG banks high-rate motion and can stream it live + * during an explicitly armed raw-data session; history also ships it in big type-0x2F offload buffers — * 124 B (~1 Hz record), 1244 B and 2140 B (~32 and ~59 sub-records per timestamped second). NOOP's * historical decoder pulls the 1 Hz gravity vector out and DISCARDS the high-rate remainder. Keeping the * big (>= [minBufferBytes]) type-0x2F buffers raw, in their own file, lets a byte-perfect decoder be diff --git a/android/app/src/main/java/com/noop/ble/PuffinExperiment.kt b/android/app/src/main/java/com/noop/ble/PuffinExperiment.kt index 1f811bd05e..a52302186e 100644 --- a/android/app/src/main/java/com/noop/ble/PuffinExperiment.kt +++ b/android/app/src/main/java/com/noop/ble/PuffinExperiment.kt @@ -111,6 +111,118 @@ class PuffinExperiment(private val prefs: SharedPreferences) { get() = prefs.getBoolean(KEY_MOTION_AWARE_WAKE, false) set(v) = prefs.edit().putBoolean(KEY_MOTION_AWARE_WAKE, v).apply() + /** + * Send the CLIENT_HELLO even when the suppression latch says not to (default false, #1635). + * + * The latch exists because the hello was never once acknowledged and the drop is locked to it. But an + * HCI capture has since changed the premise it was reasoned from: the strap answers `createBond` with + * SMP `Pairing Not Supported` (0x05), so the encrypted bond the hello was waiting behind can NEVER + * arrive. With SMP unavailable and the hello suppressed, the app now attempts NEITHER handshake — the + * same capture shows zero writes to fd4b0002 other than DISABLE_ALARM, and zero puffin subscriptions. + * + * Whether the strap will answer a hello on a link it has explicitly refused to encrypt is unknown, and + * unknowable without asking. This switch asks. It is deliberately its own toggle rather than a change + * to the latch: the latch's reasoning is still sound for anyone whose strap DOES bond, and the failure + * mode it prevents (hello, drop at ~4.8s, reconnect, forever) is real. Opting in accepts that loop in + * exchange for the answer. + */ + var helloDespiteBondRefusal: Boolean + get() = prefs.getBoolean(KEY_HELLO_DESPITE_REFUSAL, false) + set(v) = prefs.edit().putBoolean(KEY_HELLO_DESPITE_REFUSAL, v).apply() + + /** + * Try the historical offload on a link that never bonded (#1635, default false). + * + * `beginBackfill` is gated on `connectHandshakeDone`, which for a 5/MG is set only behind the + * CLIENT_HELLO ack — so on a strap answering SMP `Pairing Not Supported` the offload is never even + * attempted. That gate is ours, and the assumption underneath it (that the puffin notify chars need an + * encrypted link) has never been measured on Android: the one attempt rode a false bond and the link + * died before any answer came back. See [shouldProbeUnbondedOffload] for the staged form. + * + * Its own switch because it SENDS to the strap and, if the strap answers, writes its clock — the same + * line every other state-changing probe here sits behind ([isDeepDataEnabled], [broadcastHr], + * [ecgRawData]). Nothing is written until the strap has proved it answers a read-only GET_CLOCK, and a + * refusal is latched per device and silence spends a bounded, persisted budget, so opting in costs at + * most [UNBONDED_PROBE_MAX_SILENT_LINKS] links on a strap and not a loop. It said "one link and not a + * loop" while costing 18 across 24 connects, which is the correction this doc exists to record. + * + * Turning it ON also clears every strap's silence budget ([unbondedProbeSilentLinksPrefKey]). That + * budget is now persisted, so without this a strap that spent it would never probe again — and + * silently, the give-up line having latched on a run the user may never have seen. This setter is the + * one place the intent is unambiguous: sampling the switch at connect cannot see it flipped off and + * on while the link sits idle, which is exactly what a user does after being told to turn it off. + */ + var unbondedOffload: Boolean + get() = prefs.getBoolean(KEY_UNBONDED_OFFLOAD, false) + set(v) { + val rearms = unbondedProbeBudgetRearms(v, prefs.getBoolean(KEY_UNBONDED_OFFLOAD, false)) + val e = prefs.edit().putBoolean(KEY_UNBONDED_OFFLOAD, v) + // Every strap, not just the connected one: the switch is global, so "try again" is too, and + // this setter is the only path that runs with no device in hand. + if (rearms) { + prefs.all.keys + .filter { it.startsWith(UNBONDED_PROBE_SILENT_LINKS_KEY_PREFIX) } + .forEach { e.remove(it) } + // #1804: clear the inconclusive budget too, so re-arming gives the probe a fresh + // start on a strap whose every link was torn down locally. + prefs.all.keys + .filter { it.startsWith(UNBONDED_PROBE_INCONCLUSIVE_LINKS_KEY_PREFIX) } + .forEach { e.remove(it) } + } + e.apply() + } + + /** + * The probe's persisted silence budget for one strap — links that subscribed the puffin + * characteristics and drew no answer, capped by [UNBONDED_PROBE_MAX_SILENT_LINKS]. + * + * It lives HERE, and not beside the refusal latch in `NoopPrefs`, on purpose. The setter above clears + * these by prefix because it has no device in hand, and a sweep can only reach its own prefs file: + * written to `NoopPrefs` and swept from `noop_experiments`, re-enabling the switch would clear nothing + * and the probe would stay retired forever, silently. Keeping the budget on the object that owns the + * switch makes that drift unrepresentable rather than merely documented. + * + * Unreadable prefs read as 0 — the probe's other gates bound it, and a prefs failure must not be the + * thing that keeps a spent budget spent. + */ + fun unbondedProbeSilentLinks(peripheralId: String?): Int = runCatching { + unbondedProbeSilentLinksPrefKey(peripheralId)?.let { prefs.getInt(it, 0) } ?: 0 + }.getOrDefault(0) + + /** Record that budget. A null address (no device in hand) is a no-op, as the read is. */ + fun setUnbondedProbeSilentLinks(peripheralId: String?, value: Int) { + runCatching { + unbondedProbeSilentLinksPrefKey(peripheralId)?.let { + prefs.edit().putInt(it, value).apply() + } + } + } + + /** + * The probe's persisted inconclusive budget for one strap — links that ended in a LOCAL teardown + * (status=22), capped by [UNBONDED_PROBE_MAX_INCONCLUSIVE_LINKS]. + * + * #1804: a local teardown is inconclusive about the strap (our own stack ended the link), so it + * does NOT charge the silence budget. But it charges THIS budget, so a strap whose every link is + * torn down locally does not retry forever. Larger cap than the silence budget because + * inconclusive is genuinely weaker evidence than silence. + * + * Lives HERE for the same reason the silence budget does: the switch's setter clears these by + * prefix and can only sweep its own prefs file. + */ + fun unbondedProbeInconclusiveLinks(peripheralId: String?): Int = runCatching { + unbondedProbeInconclusiveLinksPrefKey(peripheralId)?.let { prefs.getInt(it, 0) } ?: 0 + }.getOrDefault(0) + + /** Record the inconclusive budget. A null address is a no-op, as the read is. */ + fun setUnbondedProbeInconclusiveLinks(peripheralId: String?, value: Int) { + runCatching { + unbondedProbeInconclusiveLinksPrefKey(peripheralId)?.let { + prefs.edit().putInt(it, value).apply() + } + } + } + /** True if the user opted in to "Ask Android to pair" (#1635, default false): NOOP calls * `BluetoothDevice.createBond()` explicitly instead of relying on a write to the encrypted * characteristic to provoke pairing — which the #1639 bond-state trace showed never happens at all. @@ -122,13 +234,14 @@ class PuffinExperiment(private val prefs: SharedPreferences) { set(v) = prefs.edit().putBoolean(KEY_EXPLICIT_BOND, v).apply() /** - * Turn OFF every 5/MG-only experimental probe: protocol probes ([isEnabled]), raw capture - * ([isCaptureEnabled]), the R22 deep-data strap write ([isDeepDataEnabled]) and broadcast-HR - * ([broadcastHr]). Called on a strap FAMILY switch (WHOOP 4.0 ↔ 5/MG) so a 5/MG-only option can - * never linger enabled and get applied to a strap it doesn't belong to. One atomic edit. + * Turn OFF every 5/MG-only experimental probe — exactly the switches in [FIVE_MG_GATED_KEYS], which is + * the one list rather than a prose copy of it that drifts (this doc named four while the list already + * held six). Called on a strap FAMILY switch (WHOOP 4.0 ↔ 5/MG) so a 5/MG-only option can never linger + * enabled and get applied to a strap it doesn't belong to. One atomic edit. * - * The line is "does it SEND something to the strap": these four arm probes, raw-capture writes, the - * R22 deep-data write and the broadcast-HR write, all of which target hardware that may not support + * The line is "does it SEND something to the strap": these arm probes, raw-capture writes, the R22 + * deep-data write, the broadcast-HR write, the ECG gate, an explicit pairing and the unbonded offload + * probe, all of which target hardware that may not support * them. Pure analysis flags are deliberately left alone even when they only do anything on one * family — [ppgHrSubLagInterp] only affects v26 optical records, which a 4.0 never sends, so it is * inert rather than misapplied. [experimentalSleepV2], [hrvReadiness] and [motionAwareWake] are @@ -140,6 +253,15 @@ class PuffinExperiment(private val prefs: SharedPreferences) { editor.apply() } + /** + * "Clear a stale phone pairing" - may NOOP call removeBond() when the OS holds a pairing the strap + * no longer honours? Default OFF, like every other switch here that changes hardware or OS state. + * See [shouldRemoveStaleBond] for the gate and why the threshold is above the guide's. + */ + var clearStaleBond: Boolean + get() = prefs.getBoolean(KEY_CLEAR_STALE_BOND, false) + set(v) { prefs.edit().putBoolean(KEY_CLEAR_STALE_BOND, v).apply() } + companion object { /** Persisted preferences file. Internal so a UI screen can observe external writes to it. */ internal const val PREFS = "noop_experiments" @@ -164,10 +286,21 @@ class PuffinExperiment(private val prefs: SharedPreferences) { * so no macOS key to mirror. */ const val KEY_EXPLICIT_BOND = "noopWhoop5ExplicitBond" + /** "Clear a stale phone pairing" opt-in — see [shouldRemoveStaleBond]. */ + const val KEY_CLEAR_STALE_BOND = "noopWhoop5ClearStaleBond" + + /** "Try history sync without pairing" opt-in — the unbonded offload probe (#1635). Android-only, + * so no macOS key to mirror. */ + const val KEY_UNBONDED_OFFLOAD = "noopWhoop5UnbondedOffload" + + /** #1635: send the CLIENT_HELLO even when the suppression latch is set. Default OFF. */ + const val KEY_HELLO_DESPITE_REFUSAL = "noopWhoop5HelloDespiteRefusal" + /** The 5/MG-only probe keys, in ONE place: [resetFiveMGGatedProbes] clears exactly these, and * SettingsScreen watches exactly these for external writes. Two lists would drift. */ internal val FIVE_MG_GATED_KEYS = - listOf(KEY, KEY_CAPTURE, KEY_DEEP_DATA, KEY_BROADCAST_HR, KEY_ECG_RAW_DATA, KEY_EXPLICIT_BOND) + listOf(KEY, KEY_CAPTURE, KEY_DEEP_DATA, KEY_BROADCAST_HR, KEY_ECG_RAW_DATA, KEY_EXPLICIT_BOND, + KEY_UNBONDED_OFFLOAD, KEY_CLEAR_STALE_BOND) /** "Experimental sleep staging (V2)" opt-in (mirrors macOS `PuffinExperiment.experimentalSleepV2Key`). */ const val KEY_EXPERIMENTAL_SLEEP_V2 = "noopExperimentalSleepV2" diff --git a/android/app/src/main/java/com/noop/ble/ScanAdvertisementSummary.kt b/android/app/src/main/java/com/noop/ble/ScanAdvertisementSummary.kt new file mode 100644 index 0000000000..4b60398b92 --- /dev/null +++ b/android/app/src/main/java/com/noop/ble/ScanAdvertisementSummary.kt @@ -0,0 +1,92 @@ +package com.noop.ble + +/** + * A redacted description of what a strap ADVERTISED, for the #1635 pairing-mode question. + * + * The one thing no strap log can currently answer is the question the field report put back to the + * thread: *was the strap in pairing mode during any of those refusals?* A strap that accepts pairing + * almost certainly advertises differently from one that refuses, but the scan path reads only the + * device name and discards the rest, so the evidence is thrown away at the moment it exists. + * + * It is also the ONLY diagnostic on this path that still works unbonded. The event census and the + * battery-pack read both ride characteristics that need an encrypted link, so on the strap we are + * actually trying to debug they are silent by construction. An advertisement arrives before any of + * that. + * + * STRUCTURE, NOT PAYLOAD. The local name can carry a person's name ("'s Whoop" is what WHOOP + * sets by default), service data can carry a serial, and manufacturer data is opaque. So this reports + * what is PRESENT and how big it is — flags, which service UUIDs, which data blocks and their + * lengths — and never a byte of any of them. That is enough to tell two advertising modes apart, + * which is the whole question, and carries nothing identifying. + * + * Pure and platform-free so both platforms format it identically and it can be tested without a radio. + * Swift twin: `ScanAdvertisementSummary`. + */ +object ScanAdvertisementSummary { + + /** + * @param flags the AD flags byte, or null when the advertisement carries none. + * @param serviceUuids advertised service UUIDs, lowercased; short forms are canonicalised here. + * @param serviceDataLengths bytes per service-data UUID, keyed by that UUID. + * @param manufacturerDataLengths bytes per manufacturer id. + * @param txPower advertised TX power, or null. + * @param localNameLength the local name's size in UTF-8 BYTES (see [localNameLength]) — its SIZE can + * differ between advertising modes, and unlike the name itself it identifies nobody. + * @param connectable whether the advertisement was connectable. + */ + fun line( + flags: Int?, + serviceUuids: List, + serviceDataLengths: Map, + manufacturerDataLengths: Map, + txPower: Int?, + localNameLength: Int?, + connectable: Boolean, + ): String { + val parts = mutableListOf() + parts += "flags=" + (flags?.let { "0x%02x".format(it) } ?: "none") + parts += "connectable=$connectable" + val svc = serviceUuids.map { canonicalUuid(it) }.sorted() + parts += "svc=" + if (svc.isEmpty()) "none" else svc.joinToString(",") + // Normalise BEFORE sorting: the canonical form reorders keys that the short form would not. + val svcData = serviceDataLengths.map { canonicalUuid(it.key) to it.value }.sortedBy { it.first } + parts += "svcData=" + if (svcData.isEmpty()) "none" else + svcData.joinToString(",") { "${it.first}:${it.second}B" } + parts += "mfg=" + if (manufacturerDataLengths.isEmpty()) "none" else + manufacturerDataLengths.entries.sortedBy { it.key }.joinToString(",") { "0x%04x:%dB".format(it.key, it.value) } + parts += "tx=" + (txPower?.toString() ?: "none") + parts += "nameLen=" + (localNameLength?.toString() ?: "none") + return "[adv] " + parts.joinToString(" ") + } + + /** + * Expand an assigned short Bluetooth UUID to its canonical 128-bit form; pass anything else through. + * + * A no-op on Android, whose `UUID.toString()` is always already 128-bit. It exists because + * CoreBluetooth renders an assigned 16-bit UUID as "180d" and a 32-bit one as "0000180d", so an + * unnormalised iOS capture of the same strap could not be compared against an Android one — which is + * exactly what this line exists to allow. Kept in BOTH twins so the formatters stay behaviourally + * identical rather than agreeing only by accident of what their callers happen to pass. + */ + internal fun canonicalUuid(s: String): String = when (s.length) { + 4 -> "0000$s-0000-1000-8000-00805f9b34fb" + 8 -> "$s-0000-1000-8000-00805f9b34fb" + else -> s + } + + /** + * The local name's size in UTF-8 BYTES — what the advertisement actually carries. + * + * NOT `String.length`. Java counts UTF-16 code units and Swift's `String.count` counts grapheme + * clusters, so a strap named "Whoop \uD83C\uDF89" reported 8 here and 7 on iOS — the same divergence, + * and for the same reason, as the short-UUID spelling this file already canonicalises. Names carry + * emoji and accents routinely (WHOOP seeds the name from the account holder), so this was not a + * theoretical case. + * + * Bytes rather than either platform's string model because the AD local-name field IS a UTF-8 byte + * run: it is the physically meaningful number, and it is identical on both platforms by construction + * instead of by one imitating the other. + */ + internal fun localNameLength(name: String?): Int? = name?.toByteArray(Charsets.UTF_8)?.size + +} diff --git a/android/app/src/main/java/com/noop/ble/SourceCoordinator.kt b/android/app/src/main/java/com/noop/ble/SourceCoordinator.kt index f56d4de32b..d3d1897e23 100644 --- a/android/app/src/main/java/com/noop/ble/SourceCoordinator.kt +++ b/android/app/src/main/java/com/noop/ble/SourceCoordinator.kt @@ -438,8 +438,11 @@ class SourceCoordinator( context = ctx, deviceId = id, liveSink = liveSink, - persist = { batch: StreamBatch, deviceId: String -> - scope.launch { runCatching { repo.insert(batch, deviceId) } } + // Hand the OUTCOME back. This was `runCatching { ... }` with no onFailure while the + // source had already cleared its buffer, so a rejected batch vanished with the + // surrounding log still reading like a healthy stream. + persist = { batch: StreamBatch, deviceId: String, done -> + scope.launch { done(runCatching { repo.insert(batch, deviceId) }) } }, log = straplog, // generic-HR lifecycle → the SAME exported strap log (issue #421) onBattery = batterySink, // strap battery → the same live state the WHOOP strap battery uses @@ -531,7 +534,7 @@ class SourceCoordinator( runCatching { val from = s.startTs - 16 * 3600 - 3600 val to = s.endTs + 3600 - repo.sleepSessions(deviceId, from, to, 64) + repo.sleepSessionsForDevice(deviceId, from, to, 64) .filter { it.startTs != s.startTs && com.noop.analytics.SleepSessionDedup.isDuplicate(session, it) } .forEach { e -> straplog("Oura: dup-gen(#1284) persist ${dupGenShape(s.startTs, s.endTs, s.stagesJson)} duplicates stored ${dupGenShape(e.startTs, e.endTs, e.stagesJSON)} startDelta=${s.startTs - e.startTs}s (end-anchor drift) - cross-connection DB read") @@ -544,7 +547,7 @@ class SourceCoordinator( // candidate (safe default). Mirrors the Swift twin's structure + FAILED log. val from = s.startTs - 16 * 3600 - 3600 val to = s.endTs + 3600 - val nearby = runCatching { repo.sleepSessions(deviceId, from, to, 64) }.getOrDefault(emptyList()) + val nearby = runCatching { repo.sleepSessionsForDevice(deviceId, from, to, 64) }.getOrDefault(emptyList()) val plan = com.noop.analytics.SleepSessionDedup.planBank(session, nearby) if (plan.bank) { // Bank the survivor FIRST, retire what it supersedes only after it lands — so a @@ -644,9 +647,13 @@ class SourceCoordinator( return isWhoop(device) } - /** A device is WHOOP when its id is "my-whoop" or its brand is "WHOOP" (the seeded row's brand). */ - fun isWhoop(device: PairedDeviceRow): Boolean = - device.id == WhoopBleClient.DEFAULT_DEVICE_ID || - device.brand.equals("WHOOP", ignoreCase = true) + /** + * A device is WHOOP when its id is "my-whoop" or its brand is "WHOOP" (the seeded row's brand). + * + * #1881 gave the BLE client the same question to answer, so the rule now lives in ONE place + * ([SourceIdentity]) and this delegates. Two spellings of "is this a WHOOP" that could disagree is + * precisely how a strap's samples end up filed under a ring. + */ + fun isWhoop(device: PairedDeviceRow): Boolean = SourceIdentity.isWhoop(device) } } diff --git a/android/app/src/main/java/com/noop/ble/SourceIdentity.kt b/android/app/src/main/java/com/noop/ble/SourceIdentity.kt new file mode 100644 index 0000000000..62b307b5ea --- /dev/null +++ b/android/app/src/main/java/com/noop/ble/SourceIdentity.kt @@ -0,0 +1,48 @@ +package com.noop.ble + +import com.noop.data.PairedDeviceRow + +/** + * Which registry device a live BLE link's samples belong to (#1881). + * + * Split out of [WhoopBleClient] because it is the whole of the decision and none of the Bluetooth: BLE + * behaviour cannot be tested in CI, but *this* can, and it is the part that silently files rows under the + * wrong device when it is wrong. Swift twin: `WhoopStore.SourceIdentity`. + * + * The bug it exists to prevent: the client held one `deviceId` meaning "the active device" and reused it as + * "the device these bytes came from". Those were the same thing only while every registered device was a + * WHOOP. Once a ring could be active, a WHOOP connection filed its samples — including `stepSample` and + * `gravitySample`, which only a strap can produce — under the ring's id. + */ +object SourceIdentity { + + /** + * The device id this connection's samples should be stored under, or null to leave the current id alone. + * + * Conservative by construction. It returns an id ONLY when a registry row is both matched by address and + * a WHOOP, so every uncertain case keeps today's behaviour rather than guessing: + * - [address] null/blank, or no row carries it -> null. This is the legacy single-WHOOP row before it + * has adopted a `peripheralId`; the coordinator adopts one on this same connect, so the next resolves. + * - the matched row is not a WHOOP -> null. Nothing else should arrive on the WHOOP callback, and if it + * does, re-pointing the strap's id at it would be the very bug this prevents. + * - the matched row is already the current id -> null, so there is no write on the common path. + * + * Matching is case-insensitive because the two platforms disagree about case (an Apple UUID string vs an + * Android MAC), and neither is guaranteed by the OS. + */ + fun resolve(address: String?, rows: List, currentId: String): String? { + if (address.isNullOrBlank()) return null + val row = rows.firstOrNull { it.peripheralId?.equals(address, ignoreCase = true) == true } + ?: return null + if (!isWhoop(row) || row.id == currentId) return null + return row.id + } + + /** + * A device is a WHOOP when it is the seeded legacy row or its brand says so. Kept here beside the + * resolver rather than reaching into [SourceCoordinator], so this file has no dependency to test + * around. Byte-identical to `SourceCoordinator.isWhoop` on both platforms. + */ + fun isWhoop(device: PairedDeviceRow): Boolean = + device.id == WhoopBleClient.DEFAULT_DEVICE_ID || device.brand.equals("WHOOP", ignoreCase = true) +} diff --git a/android/app/src/main/java/com/noop/ble/StaleBondRemoval.kt b/android/app/src/main/java/com/noop/ble/StaleBondRemoval.kt new file mode 100644 index 0000000000..fbab12a515 --- /dev/null +++ b/android/app/src/main/java/com/noop/ble/StaleBondRemoval.kt @@ -0,0 +1,99 @@ +package com.noop.ble + +/** + * When the app may clear a STALE platform pairing on the user's behalf. + * + * A bonded fast-path connect that drops before it ever reaches a session means the OS is holding a bond + * the strap no longer honours — a firmware update, or the official WHOOP app re-bonding, leaves Android + * with a pairing the band has forgotten. NOOP has always detected this ([WhoopBleClient] logs "stale OS + * bond") and, from the second failure, shown the forget-and-re-pair guide. Asking is the right first + * move, and it stays: this is what happens when asking did not work. + * + * OFF by default and behind its own switch, per the rule every strap-affecting probe in + * [PuffinExperiment] follows. `removeBond()` deletes a pairing from the phone's Bluetooth settings — + * at least as consequential as `createBond()` forming one, which [shouldRequestExplicitBond] already + * gates the same way — so it must not ride in on consent given for something else. + * + * Independently arrived at by OpenStrap/edge, whose gen5 bootstrap removes the platform bond once after + * five consecutive exchange failures and clears the counter only when a connect reaches READY. The + * threshold and the once-only shape are taken from that behaviour as a fact; none of its code is. + * + * PARITY: Android-only, and the reason is IDENTITY rather than a missing API — worth stating precisely, + * because "CoreBluetooth has no bond-removal call" is true but is not the whole blocker and invites + * someone to go looking for a way round it. + * + * Apple DETECTS the same condition and gives the same advice: `BLEManager`'s `didFailToConnect` handles + * `CBError.peerRemovedPairingInformation` and publishes a forget-and-re-pair guide naming the strap. It + * simply cannot perform the step. + * + * - iOS: the same identity problem, with nothing else to reach for. The app's own guide already tells + * the user to do it in Settings, which is the honest answer rather than a claim about Apple's intent. + * - macOS ships `IOBluetooth`, a separate classic-Bluetooth framework. Whether its device removal + * works on a BLE pairing is a question that never arises: it addresses devices by MAC, and Apple's + * side never has one — `BLEManager.epitaphLine`'s doc already states it, the peripheral UUID being + * "per-install, not the hardware address". The only bridge left is matching + * `IOBluetoothDevice.pairedDevices()` by NAME, and deleting a pairing on a name match is precisely the + * mistake this file's guards exist to prevent: two straps, or a renamed device, and it removes the + * wrong one. + * + * So the gap is real and stays: both platforms detect and ask, only this one can act. If CoreBluetooth + * ever exposes the hardware address, or grows a removal call of its own, revisit it — do not close it + * with a name match. + */ + +/** + * Consecutive stale-bond failures before the pairing is cleared automatically. + * + * Five, not the two at which the guide appears: the guide asks the user to do this themselves, and they + * deserve several reconnect cycles to act before the app does it for them. A strap whose pairing really + * is stale reaches five within a couple of minutes, and every one of those cycles is a failed connect. + */ +internal const val STALE_BOND_REMOVAL_THRESHOLD = 5 + +/** + * Should this disconnect clear the platform pairing? + * + * Every clause is load-bearing: + * - [optedIn]: default-off, see the file note. No switch, no bond removal, ever. + * - [isWhoop5]: a 4.0 bonds by the route it always has. This failure mode is the 5/MG fast-path one, + * and removing a WHOOP 4 pairing would break a link that works. + * - [osBonded]: nothing to remove otherwise, and calling it anyway would log a failure that means + * nothing. + * - [consecutiveStaleFailures]: the streak, not a single drop — one stale-looking disconnect is a + * transient, and this is not an action to take on a transient. + * - [alreadyRemovedThisRun]: exactly ONCE per streak. Removing a bond and immediately failing again + * means the bond was not the problem, and repeating it would delete a pairing per reconnect for a + * cause it cannot fix. Cleared only by a genuine bond, which is the event that proves the strap and + * the phone agree again. + */ +internal fun shouldRemoveStaleBond( + optedIn: Boolean, + isWhoop5: Boolean, + osBonded: Boolean, + consecutiveStaleFailures: Int, + alreadyRemovedThisRun: Boolean, + threshold: Int = STALE_BOND_REMOVAL_THRESHOLD, +): Boolean { + if (!optedIn) return false + if (!isWhoop5) return false + if (!osBonded) return false + if (alreadyRemovedThisRun) return false + return consecutiveStaleFailures >= threshold +} + +/** + * The one line that says what was done and whether the OS accepted it. + * + * `removeBond()` is not public API and is reached by reflection, so "we asked" and "it happened" are + * genuinely different outcomes and the log must not conflate them. No PII: no MAC, no serial. + */ +internal fun staleBondRemovalLine(failures: Int, accepted: Boolean): String = + if (accepted) { + "Stale pairing: cleared the phone's Bluetooth pairing for this strap after $failures failed " + + "bonded connects. Android accepted the request; the next connect starts from an unpaired " + + "state, which is what the re-pair guide was asking you to do by hand (#1635)." + } else { + "Stale pairing: asked Android to clear this strap's pairing after $failures failed bonded " + + "connects and it REFUSED (removeBond is not public API and can be denied). Nothing has " + + "changed - clear it yourself in Settings > Bluetooth, as the re-pair guide describes." + } diff --git a/android/app/src/main/java/com/noop/ble/StalledLinkDiagnostics.kt b/android/app/src/main/java/com/noop/ble/StalledLinkDiagnostics.kt new file mode 100644 index 0000000000..58901a8ae9 --- /dev/null +++ b/android/app/src/main/java/com/noop/ble/StalledLinkDiagnostics.kt @@ -0,0 +1,280 @@ +package com.noop.ble + +/** + * Why a 5/MG link reaches "live HR works, nothing ever syncs" — said once, in the log, instead of left + * to be reconstructed from four separate lines that each state only part of it. + * + * The field log that motivated this file contained every fact needed to explain a strap that had not + * banked a row in three days, and none of them said so. `Backfill: deferred — connect handshake not done + * yet` repeated with no state; the hello's absence was visible only as a line that never appeared; and + * the deferral that caused it returned silently. Reading it required knowing that `connectHandshakeDone` + * is gated on `didBond` for WHOOP5 and not for WHOOP4, that the handshake tail is what sends SET_CLOCK, + * and that an un-clocked 5/MG discards its own sensor data. That is too much reconstruction to ask of a + * capture, and it is the reconstruction that has to happen fastest when someone reports lost data. + * + * These builders are pure so the wording is testable and cannot drift from what the counters mean. The + * counters they render live in `WhoopBleClient` and are per app process — the honest limit of an + * in-memory bound, and the same one [overrideHelloStillAllowed] documents. The loops these describe run + * within one process; a restart is not the failure mode. + */ + +/** + * The hello was not written on this link because a `createBond()` was asked for first. + * + * [ExplicitBond] already explains why that deferral never resolves on a strap answering SMP 0x05: the + * next connect requests a bond too, and defers again. What it could not do is say so at runtime — the + * deferral branch returns without a word, so a capture shows a hello that simply never happens, with the + * reason inferable only from an `explicitBondRequestLine` promising a "next connect" that reads + * identically on the first deferral and the fiftieth. + * + * [consecutive] is what makes it diagnosable. One deferral is the experiment working as designed; a + * number climbing across every connect is the permanent state, and the difference between those two is + * the entire question. Printing the count converts "why is there no hello" into one line. + * + * The override's state rides along because it is the only thing that breaks the cycle, and a reader who + * sees the deferral will immediately want to know whether the escape hatch is on and whether it has + * budget left. Naming a spent override explicitly stops it reading as an untried option. + */ +internal fun helloDeferredByExplicitBondLine( + consecutive: Int, + overrideOptedIn: Boolean, + overrideAttempts: Int, + full: Boolean = true, + cap: Int = HELLO_OVERRIDE_MAX_ATTEMPTS, +): String { + // Both switches named explicitly. The first cut printed a bare "(override off)" one clause after the + // words "the pairing experiment", and a reader reasonably took it to mean the EXPERIMENT was off - + // costing a real debugging session, because the experiment was on the whole time. A parenthetical + // reporting one setting's state while the sentence names a different setting has to say which is which. + val override = when { + !overrideOptedIn -> "experiment ON, hello override off" + overrideHelloStillAllowed(overrideAttempts, cap) -> "experiment ON, hello override on ($overrideAttempts/$cap used)" + else -> "experiment ON, hello override SPENT ($overrideAttempts/$cap)" + } + val tail = if (consecutive >= 2 && !full) { + // The guidance is a paragraph and this fires once per CONNECT, on a path documented to loop — + // HelloSuppression records 57 reconnect cycles in an hour. Printing the paragraph 57 times would + // bury the rest of the capture under advice already given, so the full text is one-shot (the + // same latch idiom as helloOverrideExhaustedLine) and later occurrences stay countable but terse. + " $consecutive consecutive connects deferred, still no bond and no hello written — see the first" + + " occurrence above for what to do." + } else if (consecutive >= 2) { + // Observed here vs cited from elsewhere, kept apart on purpose: the deferral count and the + // absent bond ARE measured on this link; SMP 0x05 is not, and cannot be without an HCI capture. + // Stating the cited cause as this strap's would be the #1635 mistake of a diagnostic claiming + // more than it observed. + " Observed on this link: $consecutive consecutive connects deferred, no bond completed, and no" + + " hello written on any of them — so the \"next connect\" this waits for is not arriving." + + " A 5/MG answering Pairing Request with SMP 0x05 produces exactly this and is the known" + + " cause (#1635), but only an HCI capture can confirm it HERE. Either way the consequence is" + + " local and certain: didBond stays false, so SET_CLOCK never runs, and an un-clocked 5/MG" + + " does not persist its own sensor data to flash. Turn the pairing experiment OFF, or the" + + " hello override ON." + } else { + " Deferred once so far — expected on the connect that asks." + } + return "WHOOP 5/MG: CLIENT_HELLO deferred by the pairing experiment ($override).$tail (#1635)" +} + +/** + * `beginBackfill` declined, with the state that decided it. + * + * The bare line named the gate and nothing else, so every occurrence looked the same whether the + * handshake was seconds from completing or structurally unreachable. These five fields separate those: + * a WHOOP5 with `didBond=false` and no hello ever written is the unreachable case, and it reads that way + * at a glance rather than after a code trace. + * + * [helloEverWrittenThisLink] earns its place by distinguishing the two ways to arrive here that need + * opposite fixes — a hello that was written and went unanswered (a strap or timing problem) from one that + * was never written at all (a local decision, and the one this log's field capture actually hit). + * + * #1802: when the structural-unreachable case applies AND the unbonded-offload probe is available but not + * opted in, the line names the one action that exists — the "Try history sync without pairing" toggle in + * Test Centre. The probe was written for precisely this strap in precisely this state, and its stage 3 is + * SET_CLOCK, the thing the structural claim says never runs. Without this hint the diagnostic reads as + * hopeless when it is not. The probe stays opt-in and self-limiting; this is about discoverability, not + * about running it automatically. + */ +internal fun backfillDeferredLine( + family: String?, + didBond: Boolean, + helloEverWrittenThisLink: Boolean, + explicitBondRequestedThisLink: Boolean, + deferralsThisLink: Int, + msSinceConnect: Long, + unbondedProbeOptedIn: Boolean = false, + unbondedProbeRetired: Boolean = false, +): String { + val since = if (msSinceConnect >= 0L) "${msSinceConnect / 1000}s" else "?" + // NULL means service discovery has not established the family yet. `connectedFamily` defaults to + // WHOOP4 and otherwise holds the PREVIOUS link's value, so printing it unconditionally would put a + // guess in the log — and a guess of WHOOP4 would suppress the explanation below on exactly the 5/MG + // this exists for. The caller reads `familyEstablished` FIRST for the happens-before it carries. + val fam = family ?: "unestablished" + val why = if (family == "WHOOP5" && !didBond && !helloEverWrittenThisLink) { + // A structural claim, not a guess about the strap: didBond is set only by the hello's own ack, + // so no hello written means this gate cannot open on this link no matter what the strap does. + val base = " No hello was written on this link, so didBond cannot become true and this gate" + + " cannot open for the rest of it. SET_CLOCK rides the same handshake tail, and an un-clocked" + + " 5/MG is hardware-known not to persist sensor data to flash (#78 fork) — so there may also" + + " be nothing banked to offload." + // #1802: name the unbonded-offload probe when it is the one action that exists for this state. + // The probe's stage 3 is SET_CLOCK — the thing the structural claim above says never runs — and + // it is designed for precisely this strap. Without this hint the diagnostic reads as hopeless + // when it is not. NOT a recommendation to run it automatically: the probe is opt-in and + // self-limiting, and #1804 showed a local teardown can produce a false negative, so the hint + // says where the toggle is rather than promising an answer. + val probe = when { + unbondedProbeOptedIn -> "" // the probe is on — its own lines say what it found + unbondedProbeRetired -> " The \"Try history sync without pairing\" experiment has retired for" + + " this strap — turn it off and on in Test Centre to retry (#1804 fixed a false negative" + + " that could have latched it)." + else -> " The \"Try history sync without pairing\" experiment in Test Centre can test whether" + + " the offload works without a bond — its stage 3 is SET_CLOCK, the thing this gate" + + " blocks (#1635, #1802)." + } + base + probe + } else { + "" + } + return "Backfill: deferred — connect handshake not done yet (family=$fam didBond=$didBond" + + " helloWrittenThisLink=$helloEverWrittenThisLink bondRequestedThisLink=" + + "$explicitBondRequestedThisLink deferrals=$deferralsThisLink sinceConnect=$since).$why" +} + +/** + * Standard-HR (0x2A37) transport-state lines — the Kotlin twins of Swift's `LivePersistTrace` + * (`Packages/StrandAnalytics`), byte-identical so an Android and an Apple log of the same stall compare + * directly. They live here, beside their caller, for the same reason [liveInsertFailedLine] does. + * + * They describe host observation and buffer/persistence state only. Nothing here carries a physiological + * measurement, and nothing claims a sensor-origin time: the BLE Heart Rate Measurement characteristic + * does not provide one, so a receipt time is the host's observation and is labelled as such. + * + * The pair that matters is offered vs inserted. [standardHrFlushAttemptLine] reports what was handed to + * the store and [standardHrFlushSucceededLine] what the store actually took, because a batch that is + * offered in full and inserted as zero is precisely the failure that otherwise reads like success. + * + * [standardHrHostReceivedLine], which this block documents, is the earliest of them. Apple emits it + * from `Collector.ingestStandardHR` for every reading, so Android emits it from + * `StandardHrSource.enqueue` at the same per-reading cadence rather than only at a flush boundary. + */ +internal fun standardHrHostReceivedLine( + hostUnixSeconds: Int, + acceptedHrRows: Int, acceptedRrRows: Int, + rejectedHrRows: Int, rejectedRrRows: Int, + pendingHrRows: Int, pendingRrRows: Int, +): String = + "standard-hr transport host-received hostUnixSec=$hostUnixSeconds" + + " acceptedHRRows=$acceptedHrRows acceptedRRRows=$acceptedRrRows" + + " rejectedHRRows=$rejectedHrRows rejectedRRRows=$rejectedRrRows" + + " pendingHRRows=$pendingHrRows pendingRRRows=$pendingRrRows" + +/** Twin of Swift `LivePersistTrace.standardHRFlushAttemptLine`. */ +internal fun standardHrFlushAttemptLine( + reason: String, + offeredHrRows: Int, + offeredRrRows: Int, +): String = + "standard-hr transport flush-attempt reason=$reason" + + " offeredHRRows=$offeredHrRows offeredRRRows=$offeredRrRows" + +/** Twin of Swift `LivePersistTrace.standardHRFlushSucceededLine`. */ +internal fun standardHrFlushSucceededLine( + reason: String, + offeredHrRows: Int, + offeredRrRows: Int, + insertedHrRows: Int, + insertedRrRows: Int, +): String = + "standard-hr transport flush-succeeded reason=$reason" + + " offeredHRRows=$offeredHrRows offeredRRRows=$offeredRrRows" + + " insertedHRRows=$insertedHrRows insertedRRRows=$insertedRrRows" + +/** Twin of Swift `LivePersistTrace.standardHRRebufferedForRetryLine`. */ +internal fun standardHrRebufferedForRetryLine( + reason: String, + attemptedHrRows: Int, + attemptedRrRows: Int, + pendingHrRows: Int, + pendingRrRows: Int, + consecutiveFailures: Int, +): String = + "standard-hr transport rebuffered-for-retry reason=$reason" + + " attemptedHRRows=$attemptedHrRows attemptedRRRows=$attemptedRrRows" + + " pendingHRRows=$pendingHrRows pendingRRRows=$pendingRrRows" + + " consecutiveFailures=$consecutiveFailures" + +/** + * Why a standard-HR flush ran. Twin of Swift's `LivePersistTrace.StandardHRFlushReason`, and the raw + * values must stay identical because they are what the log line carries. + * + * `background` and `termination` are Apple-only today: iOS suspends a connected strap without a + * disconnect edge, which Android's foreground service does not do. They exist here so the two enums do + * not drift, and so an Android answer to "should an OEM kill flush this buffer?" has a name already. + */ +internal enum class StandardHrFlushReason(val raw: String) { + CADENCE("cadence"), + DISCONNECT("disconnect"), + BACKGROUND("background"), + TERMINATION("termination"), + EXPLICIT("explicit"), +} + +/** + * A live HR/R-R batch failed to persist and was re-buffered. + * + * The catch that owns this re-queues the frames and swallows the throwable, so a store that is failing + * every insert produces a log full of `rr emit … offered=13` and no indication that none of it landed. + * That is the worst shape a diagnostic gap can take: the instrumentation that exists reads like success. + * + * [transport] is named because there are TWO live paths — the standard 0x2A37 reading and the puffin + * REALTIME_DATA batch (#1118) — and they fail independently. A line that did not say which would leave a + * reader unable to tell one dead transport from a dead store, which is the first fork in the diagnosis. + * + * [consecutiveFailures] matters more than any single throwable. One failure is a transient the re-buffer + * exists to absorb; a climbing count is a store that is never going to accept these rows, and only the + * count separates them. The message is included because the useful cases — a full disk, a corrupted + * database, a schema mismatch — are distinguished by it rather than by the class. + */ +internal fun liveInsertFailedLine( + transport: String, + throwableName: String, + message: String?, + hrFrames: Int, + rrFrames: Int, + consecutiveFailures: Int, +): String { + // Bound is ASCII-only (store errors). Kotlin `take(200)` is UTF-16 code units; Swift `prefix(200)` + // is grapheme clusters. They agree on ASCII, which is the load-bearing case — not a Unicode twin. + val detail = message?.takeIf { it.isNotBlank() }?.let { ": ${it.take(200)}" } ?: "" + val run = if (consecutiveFailures >= 2) { + " $consecutiveFailures consecutive failures — these rows are not landing and the re-buffer is" + + " not recovering them." + } else { + " Re-buffered for the next cadence." + } + return "Live persist FAILED on $transport — $throwableName$detail (hr=$hrFrames rr=$rrFrames).$run" +} + +/** + * Rate-limit for [liveInsertFailedLine]. + * + * The live cadence is seconds, so an unconditional log would bury the rest of the capture under a + * failure it has already reported. The gap is deliberately long: this line exists to establish THAT + * inserts are failing and roughly for how long, which one line a minute answers as well as sixty. + * + * A zero [lastEmitMs] must emit — the first failure is the one most worth having, and treating "never + * emitted" as "just emitted" would silence exactly the case this was built for. + * + * A BACKWARDS clock emits too. `System.currentTimeMillis()` is wall time and can step back — an NTP + * correction, a timezone-adjacent settings change, or the strap-clock skew this log family already + * tracks. Comparing only forwards would leave `lastEmitMs` stranded in the future and silence the line + * until real time caught up, which for a large step is indefinitely. Emitting on the step costs one + * extra line and cannot latch. + */ +internal fun shouldEmitLiveInsertFailure( + lastEmitMs: Long, + nowMs: Long, + minGapMs: Long = 60_000L, +): Boolean = lastEmitMs <= 0L || nowMs < lastEmitMs || nowMs - lastEmitMs >= minGapMs diff --git a/android/app/src/main/java/com/noop/ble/StandardHeartRate.kt b/android/app/src/main/java/com/noop/ble/StandardHeartRate.kt index 25c85fd0d2..4a5a7c96aa 100644 --- a/android/app/src/main/java/com/noop/ble/StandardHeartRate.kt +++ b/android/app/src/main/java/com/noop/ble/StandardHeartRate.kt @@ -1,5 +1,7 @@ package com.noop.ble +import com.noop.protocol.StandardHrContact + /** * Pure parser for the standard BLE Heart Rate Measurement characteristic (0x2A37). * @@ -20,7 +22,7 @@ package com.noop.ble object StandardHeartRate { /** The parsed reading: heart rate (bpm) and the R-R intervals (ms), in arrival order. */ - data class Reading(val hr: Int, val rr: List) + data class Reading(val hr: Int, val rr: List, val contact: StandardHrContact) /** * Parse one 0x2A37 notification payload. Returns null on an empty or truncated packet (a packet @@ -29,6 +31,7 @@ object StandardHeartRate { fun parse(data: ByteArray): Reading? { if (data.isEmpty()) return null val flags = data[0].toInt() and 0xFF + val contact = StandardHrContact.fromMeasurementFlags(flags) var idx = 1 val hr: Int @@ -52,6 +55,6 @@ object StandardHeartRate { idx += 2 } } - return Reading(hr, rr) + return Reading(hr, rr, contact) } } diff --git a/android/app/src/main/java/com/noop/ble/StandardHrSource.kt b/android/app/src/main/java/com/noop/ble/StandardHrSource.kt index e2e26bace3..835b5500e4 100644 --- a/android/app/src/main/java/com/noop/ble/StandardHrSource.kt +++ b/android/app/src/main/java/com/noop/ble/StandardHrSource.kt @@ -20,9 +20,12 @@ import android.os.Handler import android.os.Looper import android.os.ParcelUuid import com.noop.data.HrRow +import com.noop.data.InsertCounts import com.noop.data.RrRow +import com.noop.data.StandardHrMapping import com.noop.data.StreamBatch import com.noop.polar.PolarModel +import com.noop.protocol.StandardHrContact import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -57,9 +60,25 @@ class StandardHrSource( private val deviceId: String, /** Push live HR (bpm) + R-R (ms) into whatever the UI observes. Called on the main looper. */ private val liveSink: (hr: Int, rr: List) -> Unit, - /** Persist a batch under [deviceId] — wired to `repository.insert`. Called off the main looper is - * fine; the implementation hops to its own IO scope (see [SourceCoordinator]). */ - private val persist: (StreamBatch, String) -> Unit, + /** + * Persist a batch under [deviceId] — wired to `repository.insert`. Called off the main looper is + * fine; the implementation hops to its own IO scope (see [SourceCoordinator]). + * + * Reports its OUTCOME through [done], and that is the point of the third parameter. The seam used to + * return Unit and the app wired it as `runCatching { repo.insert(...) }` with no onFailure, while + * [flush] had already cleared the buffer — so a store that rejected the batch lost those rows in + * silence, with the surrounding log reading exactly like a healthy stream. The Swift Collector + * re-buffers and retries; this could not, because it was never told. + * + * [InsertCounts] rather than a bare success flag: a batch offered in full and inserted as zero is + * the failure that most looks like success, and only the store's own count distinguishes it. + * + * The re-buffer is only as good as [done] being called. The live wiring launches on a scope, and a + * scope cancelled before the body runs drops the batch with no line — the same loss the old + * `runCatching`-with-no-onFailure had, now narrowed to a cancelled teardown rather than every + * rejected insert. An implementation that can fail to answer should answer with a failure. + */ + private val persist: (StreamBatch, String, (Result) -> Unit) -> Unit, /** Diagnostic sink for the connect lifecycle. Wired (via [SourceCoordinator]) to the SAME in-app strap * log the user exports, so the generic-HR path is no longer invisible in a bug report (issue #421). * Every line is prefixed "HR-strap: " so it's distinguishable from WHOOP lines in the shared log. @@ -148,10 +167,23 @@ class StandardHrSource( // MARK: - Sample buffer (flushed in batches off the per-notification hot loop) - private data class Sample(val hr: Int, val rr: List, val ts: Long) + private data class Sample( + val hr: Int, + val rr: List, + /** Non-null ONLY when this reading changed the contact state — see [StandardHrMapping.shouldRecordContact]. */ + val contact: StandardHrContact?, + val ts: Long, + ) private val bufferLock = Any() private val buffer = ArrayList() private var lastFlushMs = System.currentTimeMillis() + /** Consecutive failed inserts, for the run-length the rate-limited failure line reports. Reset by a + * success, exactly like the WHOOP path's counter. */ + private val insertFailures = java.util.concurrent.atomic.AtomicInteger(0) + + /** Last emission of [liveInsertFailedLine], for [shouldEmitLiveInsertFailure]'s one-a-minute gap. */ + @Volatile private var lastInsertFailureLogMs = 0L + private val flushCount = 30 private val flushIntervalMs = 30_000L @@ -229,7 +261,7 @@ class StandardHrSource( loggedFirstHr = false // a later reconnect should log its first sample again loggedFirstSensor = false _batteryPct.value = null // a stale charge must not outlive the link - flush() + flush(StandardHrFlushReason.DISCONNECT) clearSensorState() // a stale speed/cadence/power panel must not outlive the link } @@ -243,31 +275,105 @@ class StandardHrSource( // MARK: - Buffer / persistence - private fun enqueue(hr: Int, rr: List) { - val shouldFlush = synchronized(bufferLock) { - buffer.add(Sample(hr, rr, System.currentTimeMillis() / 1000L)) - buffer.size >= flushCount || + /** Last contact state put in the buffer. Advanced at ENQUEUE, which is safe because the change point + * travels in the buffer until it persists: a failed flush re-buffers the sample carrying it. */ + private var lastEnqueuedContact: StandardHrContact? = null + + private fun enqueue(hr: Int, rr: List, contact: StandardHrContact) { + val ts = System.currentTimeMillis() / 1000L + val shouldFlush: Boolean + val hostLine: String + synchronized(bufferLock) { + val record = StandardHrMapping.shouldRecordContact(lastEnqueuedContact, contact) + if (record) lastEnqueuedContact = contact + buffer.add(Sample(hr, rr, if (record) contact else null, ts)) + // Twin of Collector.ingestStandardHR's host-received line. Gates on THIS sample match + // rowsOf / Swift ingest; pending is the gated contents of the raw buffer. Cadence still + // trips on buffer.size so we do not move the filter (#1770). + val acceptedHr = if (hr in 30..220) 1 else 0 + val acceptedRr = rr.count { it in 250..3000 } + val (pendingHr, pendingRr) = rowsOf(buffer) + hostLine = standardHrHostReceivedLine( + hostUnixSeconds = ts.toInt(), + acceptedHrRows = acceptedHr, acceptedRrRows = acceptedRr, + rejectedHrRows = 1 - acceptedHr, rejectedRrRows = rr.size - acceptedRr, + pendingHrRows = pendingHr.size, pendingRrRows = pendingRr.size, + ) + shouldFlush = buffer.size >= flushCount || System.currentTimeMillis() - lastFlushMs >= flushIntervalMs } + log(hostLine) if (shouldFlush) flush() } - private fun flush() { + /** + * The rows a set of buffered samples would offer, applying the SAME gates in one place. + * + * Mirrors `StandardHRMapping`: HrRow + RrRow only, HR range-gated so an off-wrist 0 is dropped, R-R + * gated to plausible beat-to-beat ms — matching `ingestStandardHr`. Shared with the re-buffer path + * deliberately: a pending count that applied different gates from the offered count would report two + * numbers that cannot be compared, in the one line whose job is comparing them. + */ + private fun rowsOf(samples: List): Pair, List> { + val hrRows = ArrayList() + val rrRows = ArrayList() + for (s in samples) { + if (s.hr in 30..220) hrRows.add(HrRow(s.ts, s.hr)) + for (r in s.rr) if (r in 250..3000) rrRows.add(RrRow(s.ts, r)) + } + return hrRows to rrRows + } + + private fun flush(reason: StandardHrFlushReason = StandardHrFlushReason.CADENCE) { val snapshot = synchronized(bufferLock) { lastFlushMs = System.currentTimeMillis() if (buffer.isEmpty()) return val s = ArrayList(buffer); buffer.clear(); s } - // Mirror StandardHRMapping: HrRow + RrRow only. HR is range-gated (off-wrist 0/garbage dropped); - // R-R is gated to physiologically plausible beat-to-beat ms, matching ingestStandardHr. - val hrRows = ArrayList() - val rrRows = ArrayList() - for (s in snapshot) { - if (s.hr in 30..220) hrRows.add(HrRow(s.ts, s.hr)) - for (r in s.rr) if (r in 250..3000) rrRows.add(RrRow(s.ts, r)) + val (hrRows, rrRows) = rowsOf(snapshot) + val contactEvents = snapshot.mapNotNull { s -> + s.contact?.let { StandardHrMapping.contactEvent(s.ts, it) } } - if (hrRows.isNotEmpty() || rrRows.isNotEmpty()) { - persist(StreamBatch(hr = hrRows, rr = rrRows), deviceId) + if (hrRows.isEmpty() && rrRows.isEmpty() && contactEvents.isEmpty()) return + log(standardHrFlushAttemptLine(reason.raw, hrRows.size, rrRows.size)) + persist(StreamBatch(hr = hrRows, rr = rrRows, events = contactEvents), deviceId) { result -> + result.fold( + onSuccess = { counts -> + insertFailures.set(0) + log(standardHrFlushSucceededLine(reason.raw, hrRows.size, rrRows.size, counts.hr, counts.rr)) + }, + onFailure = { t -> + // Put the batch BACK, at the front, so the next flush retries it in order — the Swift + // Collector's `insert(contentsOf:at:0)`. Without this the rows were already gone the + // moment the snapshot was taken. + val (pendingHr, pendingRr) = synchronized(bufferLock) { + buffer.addAll(0, snapshot) + val (h, r) = rowsOf(buffer) + h.size to r.size + } + val runLength = insertFailures.incrementAndGet() + log(standardHrRebufferedForRetryLine( + reason = reason.raw, + attemptedHrRows = hrRows.size, attemptedRrRows = rrRows.size, + pendingHrRows = pendingHr, pendingRrRows = pendingRr, + consecutiveFailures = runLength, + )) + // Rate-limited, and the SAME helper the WHOOP live path uses, so one strap log carries + // one shape of insert failure however it arrived. + val nowMs = System.currentTimeMillis() + if (shouldEmitLiveInsertFailure(lastInsertFailureLogMs, nowMs)) { + lastInsertFailureLogMs = nowMs + log(liveInsertFailedLine( + transport = "standard-hr", + throwableName = t.javaClass.simpleName, + message = t.message, + hrFrames = hrRows.size, + rrFrames = rrRows.size, + consecutiveFailures = runLength, + )) + } + }, + ) } } @@ -280,7 +386,7 @@ class StandardHrSource( val firstSight = seen.put(address, device) == null // null → not seen before this scan val name = result.scanRecord?.deviceName ?: runCatching { device.name }.getOrNull() ?: "Heart Rate Strap" - if (firstSight) log("HR-strap: found $name ($address) rssi ${result.rssi}") + if (firstSight) log("HR-strap: found ${logSafeDeviceName(name)} ($address) rssi ${result.rssi}") val strap = DiscoveredStrap(address = address, name = name, rssi = result.rssi) _discovered.value = upsertByProximity(_discovered.value, strap) // Replay a connect intent that arrived before the device was discovered. @@ -324,7 +430,7 @@ class StandardHrSource( loggedPolarIdentity = false loggedFirstSensor = false _batteryPct.value = null // a stale charge must not outlive the link - flush() + flush(StandardHrFlushReason.DISCONNECT) clearSensorState() if (gatt === g) { runCatching { g.close() }; gatt = null } // Hardening: status 133 is Android's infamous generic GATT_ERROR on connect — almost @@ -529,7 +635,7 @@ class StandardHrSource( } // Surface live HR on the main looper (the UI's StateFlow expects main-thread updates). handler.post { guardedCallback("live-sink") { liveSink(parsed.hr, parsed.rr) } } - enqueue(parsed.hr, parsed.rr) + enqueue(parsed.hr, parsed.rr, parsed.contact) } companion object { diff --git a/android/app/src/main/java/com/noop/ble/UnbondedOffloadProbe.kt b/android/app/src/main/java/com/noop/ble/UnbondedOffloadProbe.kt new file mode 100644 index 0000000000..cc5b5a3098 --- /dev/null +++ b/android/app/src/main/java/com/noop/ble/UnbondedOffloadProbe.kt @@ -0,0 +1,621 @@ +package com.noop.ble + +/** + * Should NOOP try the historical offload on a WHOOP 5/MG that has NOT bonded? + * + * The offload has always been gated on the CLIENT_HELLO ack — `beginBackfill` refuses while + * `connectHandshakeDone` is false, and for a 5/MG that flag is set in exactly one place, behind `didBond`. + * On a strap that answers SMP with `Pairing Not Supported` (#1635) the ack can never arrive, so the gate + * can never open, and the app never asks the strap for history at all. + * + * What makes that worth re-examining is that the gate is OURS, not the strap's, and the link underneath it + * is not the broken thing it was assumed to be. With the hello suppressed the link is stable for tens of + * minutes (a field capture holds one up for 2431s), live HR streams over the standard profile the whole + * time, the unbonded DIS identity read succeeds (#490/#1455), and puffin commands — DISABLE_ALARM, + * TOGGLE_REALTIME_HR — are written to fd4b0002 without the stack objecting. Every bounce in the capture + * traces to the bond watchdog after an unanswered hello, never to the link itself. + * + * What is NOT established is the part that matters, and it is important to be exact about why. Those + * puffin writes go out WITHOUT response, so their `GATT_SUCCESS` is Android reporting a Write Command + * handed to the controller — a local fact that says nothing about whether the strap parsed it. And the + * offload does not arrive on fd4b0002 at all: it arrives on the puffin NOTIFY characteristics + * (fd4b0003/4/5/7), which this app has never once subscribed on a healthy unbonded link. The single + * attempt on record (28 Aug, 13:25:00) rode a FALSE bond — a DISABLE_ALARM completion misread as a hello + * ack, the bug #1635 fixed — and produced `writeDescriptor busy; retry 1/8`, an Android queue error, + * before the link died 4s later with the hello still outstanding. No answer was ever obtained. + * + * The belief that it cannot work is a comment, not a measurement: `BLEManager.swift` records that the + * strap rejects those subscriptions with "Authentication is insufficient", from a 5/MG that was still + * bonded to the official WHOOP app (issue #17). That may well be right. It has never been tested against + * a strap in this state, on a link that stays up long enough to hear the answer. + * + * So this probe asks, in stages, each one falsifying the next: + * + * 1. subscribe the four puffin notify characteristics. An insufficient-authentication status here is the + * whole answer — the offload needs an encrypted link, and #1635 is a wall rather than a gate. + * 2. if they subscribe, send GET_CLOCK. Read-only on purpose: it changes nothing on the strap, and a + * COMMAND_RESPONSE to it is the first hard evidence that puffin commands are acted on unbonded. + * 3. only then SET_CLOCK and the ordinary offload, which is the existing hardware-proven sequence. + * + * Self-limiting the same way [shouldReadDisUnbonded] is: opt-in, once per link, and never again after a + * refusal is latched for that device. This area has a habit of producing "keeps retrying something that + * cannot work", and the point of a probe is to stop being one after it has its answer. + * + * A refusal is a RESULT. Either outcome closes the last open question on #1635. + */ +internal fun shouldProbeUnbondedOffload( + isWhoop5: Boolean, + optedIn: Boolean, + bonded: Boolean, + helloWrittenThisLink: Boolean, + alreadyProbedThisLink: Boolean, + previouslyRefused: Boolean, + silentLinksSoFar: Int, + inconclusiveLinksSoFar: Int = 0, +): Boolean { + if (!isWhoop5) return false + if (!optedIn) return false + // A bonded strap reaches the offload through the proven post-hello handshake; this exists only for the + // straps that path never reaches. + if (bonded) return false + // The hello is what drops the link. On a link carrying one, the bond watchdog has ~5s to bounce us, so + // a refusal here would be unattributable — exactly the ambiguity that made the hello unreadable for + // eleven weeks. Only the stable no-hello link can answer this question. + if (helloWrittenThisLink) return false + if (alreadyProbedThisLink) return false + return !unbondedProbeRetired(previouslyRefused, silentLinksSoFar, inconclusiveLinksSoFar) +} + +/** + * Why the unbonded-offload probe did NOT run on this link, or null when it will. + * + * [beginUnbondedOffloadProbe] returns SILENTLY when [shouldProbeUnbondedOffload] says no, and that + * silence is unreadable. A 5/MG log then shows the four puffin notify chars DISCOVERED, one standard-HR + * subscribe, and nothing further — which looks identical whether the app declined to ask or the strap + * refused. Those have opposite meanings for #1635: one is a setting, the other is the answer. + * + * Names the reason in the SAME ORDER the gate tests them, so the reason printed is the one that actually + * decided rather than the first one that happens to be true. Returns null exactly when the gate returns + * true, and the tests pin that agreement exhaustively over every input, so a new condition added to one + * cannot outlive the other. + * + * The consequence clause is appended only for an unbonded 5/MG, because that is the only case where the + * puffin chars go unsubscribed: a bonded strap reaches them through the ordinary handshake. + */ +internal fun unbondedProbeSkippedLine( + isWhoop5: Boolean, + optedIn: Boolean, + bonded: Boolean, + helloWrittenThisLink: Boolean, + alreadyProbedThisLink: Boolean, + previouslyRefused: Boolean, + silentLinksSoFar: Int, + inconclusiveLinksSoFar: Int = 0, +): String? { + val why = when { + !isWhoop5 -> "not a WHOOP 5/MG" + !optedIn -> "the unbonded-offload experiment is off" + bonded -> "this strap bonded, so the ordinary post-hello handshake reaches the offload" + helloWrittenThisLink -> + "a CLIENT_HELLO went out on this link, so a refusal here could not be attributed to the strap" + alreadyProbedThisLink -> "already probed on this link" + unbondedProbeRetired(previouslyRefused, silentLinksSoFar, inconclusiveLinksSoFar) -> + when { + previouslyRefused -> "retired for this strap: a refusal is latched" + !unbondedProbeStillWorthAsking(silentLinksSoFar) -> + "retired for this strap: the silent-link budget is spent" + else -> "retired for this strap: the inconclusive-link budget is spent (our own stack" + + " tore down every probe link, so the question was never asked of the strap)" + } + else -> return null + } + val consequence = if (isWhoop5 && !bonded) { + " The puffin notify chars stay unsubscribed on this link, so neither the historical offload nor a" + + " realtime IMU producer can reach us here." + } else { + "" + } + return "unbonded probe skipped — $why.$consequence" +} + +/** + * Has the probe stopped asking — for good, on this device? + * + * True on a latched refusal (the strap's verdict) or once the silent-link budget is spent. Extracted + * because TWO decisions depend on it and they were only wired to one: the probe retires itself correctly, + * while the handshake skip that exists TO SERVE it kept applying forever. + * + * What happens to a stranded strap once this is fixed, since "it starts writing helloes again" deserves an + * answer rather than a shrug: it resumes the ordinary handshake, a #1635 strap refuses it, and + * [BondRefusalGiveUp] latches `helloSuppressed` after its 5-refusal threshold — settling at the designed + * "Live HR, not fully paired" end state. Bounded, and strictly better than the state it replaces, which + * had no bond, no offload AND no probe. + * + * That asymmetry strands the strap. With the hello skipped `didBond` can never become true, so the + * ordinary offload gate can never open — and once the probe has retired there is nothing left the skip is + * buying. A field log shows the end state plainly: the hello skipped on every connect, no probe lines at + * all, `didBond=false` and `Backfill: deferred` nine times across sixteen hours. The strap could neither + * bond nor sync, in service of a question that had already stopped being asked. + */ +internal fun unbondedProbeRetired( + previouslyRefused: Boolean, + silentLinksSoFar: Int, + inconclusiveLinksSoFar: Int = 0, +): Boolean = + previouslyRefused + || !unbondedProbeStillWorthAsking(silentLinksSoFar) + || !unbondedProbeStillWorthAskingInconclusive(inconclusiveLinksSoFar) + +/** + * How many links may end in SILENCE before the probe retires itself. + * + * A refusal is the strap's verdict and latches outright; silence is weaker evidence — the subscriptions + * were accepted, and one quiet link says little — so it spends a budget instead. But weaker evidence + * cannot mean unbounded: the probe re-runs on every reconnect, and a strap that reconnects often would + * re-ask a question already answered the same way three times, which is the exact shape of the retry this + * whole area keeps producing. + * + * Counted PER DEVICE and persisted ([unbondedProbeSilentLinksPrefKey]), not per process. It was per + * process, and that was wrong in the field: the foreground service restarts often, every restart re-armed + * three more links, and each of those links is torn down ~4.8s after the subscriptions reach the air. One + * capture caught 18 probe starts across 24 connects — the unbounded retry this budget exists to prevent, + * surviving it by outliving the only thing that bounded it. Bounded within a process and endless across + * them is not bounded. + * + * Cleared by a genuine answer, and by turning the experiment off and on — see [PuffinExperiment]. + */ +internal const val UNBONDED_PROBE_MAX_SILENT_LINKS = 3 + +/** + * How many CONSECUTIVE LOCAL TEARDOWNS may end a probe before it retires itself. + * + * #1804: a local teardown (status=22) is inconclusive about the strap — our own stack ended the + * link, not the strap — so it does NOT charge the silence budget. But inconclusive cannot mean + * unbounded: the probe re-runs on every reconnect, and on the strap this fix was written for + * EVERY attempt was a local teardown. Without a cap the probe re-runs indefinitely on precisely + * the device the fix was written for, with no path to a conclusion. + * + * The cap is LARGER than the silence budget because inconclusive is genuinely weaker evidence + * than silence: a silent link at least proved the subscriptions were accepted, while a local + * teardown proved nothing about the strap at all. If our stack tears the link down every time, + * the probe cannot complete regardless of what the strap would have said, and that is worth + * recording and stopping on too. + * + * Like the silence budget, cleared by a genuine answer and by turning the experiment off and on. + */ +internal const val UNBONDED_PROBE_MAX_INCONCLUSIVE_LINKS = 6 + +/** Has the probe any budget left after [silentLinksSoFar] links that ended without an answer — whether + * they subscribed and stayed quiet, or were torn down while being asked? */ +internal fun unbondedProbeStillWorthAsking( + silentLinksSoFar: Int, + cap: Int = UNBONDED_PROBE_MAX_SILENT_LINKS, +): Boolean = silentLinksSoFar < cap + +/** Has the probe any inconclusive budget left after [inconclusiveLinksSoFar] links ended in a + * LOCAL teardown (status=22)? #1804: a local teardown is weaker than silence, so it gets its own + * larger cap — but it is still bounded, so a strap whose every link is torn down locally does not + * retry forever. */ +internal fun unbondedProbeStillWorthAskingInconclusive( + inconclusiveLinksSoFar: Int, + cap: Int = UNBONDED_PROBE_MAX_INCONCLUSIVE_LINKS, +): Boolean = inconclusiveLinksSoFar < cap + +/** + * The line printed when the probe retires, so the log says why it stopped rather than leaving a reader to + * notice its absence — the same failure the CLIENT_HELLO's silent suppression caused. + * + * It must NOT claim the strap served the subscriptions. It said so, and that was only ever true of the + * links that subscribed and then stayed quiet. Since #1749 a link LOST mid-probe charges the same budget, + * and those links confirm no subscribes at all — so on the 31 Aug capture, where every charge was a lost + * link, this line would have recorded "serves those characteristics unbonded" about a strap that had + * demonstrated the opposite. Overclaiming from the weaker evidence is the mistake this file is built to + * avoid, and the retirement line is exactly where a future reader goes looking. The per-link lines + * ([unbondedProbeLinkLostLine], [unbondedProbeSilentLine]) already say which happened each time; this one + * states only what holds either way. + */ +internal fun unbondedProbeGaveUpLine(silentLinks: Int): String = + "Unbonded offload probe: $silentLinks links have ended with no COMMAND_RESPONSE — this strap either" + + " does not act on puffin commands written over an unencrypted link, or does not hold the link up" + + " long enough to answer one; the per-link lines above say which happened each time." + + " Not asking this strap again — turn the experiment off and on to retry." + + " History offload is unavailable until the strap" + + " completes a handshake (#1635)." + +/** + * Does opting into the unbonded offload probe replace the handshake attempt on this connect? + * + * Found in the field, and it is the difference between the probe running and never running at all. + * [shouldProbeUnbondedOffload] requires a link carrying NO hello, because a refusal on a link the bond + * watchdog is about to bounce is unattributable. But the probe is only SCHEDULED from the two no-hello + * branches, and on this strap neither is reached in practice: across 41 captures the suppression latch + * fired in three, all on one day, and never since — while the explicit-bond deferral yields after its + * first connect by design (#1642), so every subsequent link writes a hello. The probe would have sat + * behind a state the app almost never enters. + * + * The resolution is not to loosen the attributability gate, which is what makes the answer worth having. + * It is to notice that these are MUTUALLY EXCLUSIVE experiments: "does the hello work" and "is the hello + * needed at all" cannot both be asked of one link, because asking the first is what destroys the second's + * link. Turning this switch on is choosing the second question, so it supersedes the handshake for that + * connect — both the explicit pairing request and the hello itself. + * + * [userInitiated] still wins, exactly as it does for the suppression latch. Pressing Connect is an + * explicit request for the handshake, and it must never be answered with a different experiment. + * + * Narrow on purpose: 5/MG only, never on a strap that has already bonded (that one reaches the offload the + * proven way and has nothing to prove), and default off, so no one who has not asked for this is affected. + */ +internal fun unbondedProbeSupersedesHandshake( + optedIn: Boolean, + isWhoop5: Boolean, + appLevelBonded: Boolean, + userInitiated: Boolean, + /** + * The probe has stopped asking ([unbondedProbeRetired]) — so skipping the hello now buys nothing and + * costs the strap its bond and its offload. Suppressing the handshake is only defensible while + * something is using the link it creates. + */ + probeRetired: Boolean, +): Boolean = optedIn && isWhoop5 && !appLevelBonded && !userInitiated && !probeRetired + +/** + * Said once per superseded connect, because a hello that is absent looks identical to one that failed + * silently — the ambiguity that made #1635 unreadable for eleven weeks. + * + * Names the explicit-bond switch when it is also on. The two do not conflict fatally, but a pairing in + * flight makes a subscribe refusal unattributable to the strap, so the probe declines to latch it and the + * capture is weaker for no gain. + */ +internal fun unbondedProbeSupersedesLine(explicitBondOptedIn: Boolean): String = + "WHOOP 5/MG: handshake skipped for this connect — \"try history sync without pairing\" is on, and it" + + " asks whether the offload needs a bond at all. That question needs a link with no CLIENT_HELLO on" + + " it, so the hello is not written here (press Connect to try the handshake instead)." + + (if (explicitBondOptedIn) + " \"Ask Android to pair\" is on but is ALSO skipped on this connect — this branch returns" + + " before the pairing request, so no SMP is in flight and a refusal here is attributable" + + " to the strap." + else "") + + " (#1635, experimental)" + +/** Persisted key for "this strap refused the unbonded puffin subscriptions". Per device and lowercased, + * for the same reason [firmwarePrefKey] is. */ +internal fun unbondedOffloadRefusedPrefKey(peripheralId: String?): String? = + peripheralId?.trim()?.takeIf { it.isNotEmpty() }?.let { "noop.unbondedOffloadRefused.${it.lowercase()}" } + +/** + * Does writing [PuffinExperiment.unbondedOffload] hand every strap's silence budget back? + * + * The off->ON EDGE, and not merely "on". A setter that cleared on every true would return the budget to + * any caller that rewrites the current value, and the budget's whole job is to stop a retry that takes the + * link down with it. Only the two switches write it today; making this the setter's property rather than + * its callers' is what keeps that true. + */ +internal fun unbondedProbeBudgetRearms(optedInNow: Boolean, optedInBefore: Boolean): Boolean = + optedInNow && !optedInBefore + +/** Prefix of every persisted silence budget, so opting back in can clear them all without knowing which + * straps have one. Sole reason it is a constant rather than an inlined string. */ +internal const val UNBONDED_PROBE_SILENT_LINKS_KEY_PREFIX = "noop.unbondedOffloadSilentLinks." + +/** + * Persisted key for "how many links on this strap subscribed the puffin characteristics and drew no + * answer" — the silence budget of [UNBONDED_PROBE_MAX_SILENT_LINKS], per device and lowercased for the + * same reason [firmwarePrefKey] is. + * + * Deliberately NOT [unbondedOffloadRefusedPrefKey], though a spent budget stops the probe just as a + * refusal does. A refusal is the strap's answer and is final; a spent budget is ours — we stopped asking — + * and the user can hand it back. Sharing one key would have the log report a refusal that never happened, + * which is precisely the conflation [UnbondedProbeEvidence] exists to prevent. + */ +internal fun unbondedProbeSilentLinksPrefKey(peripheralId: String?): String? = + peripheralId?.trim()?.takeIf { it.isNotEmpty() } + ?.let { UNBONDED_PROBE_SILENT_LINKS_KEY_PREFIX + it.lowercase() } + +/** Prefix of every persisted inconclusive budget, cleared alongside the silence budget when the + * experiment is re-armed. #1804: a local teardown charges this instead of the silence budget, and + * it has its own larger cap ([UNBONDED_PROBE_MAX_INCONCLUSIVE_LINKS]). */ +internal const val UNBONDED_PROBE_INCONCLUSIVE_LINKS_KEY_PREFIX = "noop.unbondedOffloadInconclusiveLinks." + +/** Persisted key for "how many probe links on this strap ended in a LOCAL teardown (status=22)" — + * the inconclusive budget of [UNBONDED_PROBE_MAX_INCONCLUSIVE_LINKS], per device. */ +internal fun unbondedProbeInconclusiveLinksPrefKey(peripheralId: String?): String? = + peripheralId?.trim()?.takeIf { it.isNotEmpty() } + ?.let { UNBONDED_PROBE_INCONCLUSIVE_LINKS_KEY_PREFIX + it.lowercase() } + +/** + * What a frame arriving on a puffin notify characteristic proves about an unbonded link. + * + * The distinction is the whole point of the probe and is easy to lose. A frame — any frame — proves the + * strap SERVES those characteristics without encryption, which is what the offload transport needs. It + * does not prove the strap ACTED on anything we wrote, because a stream it would have sent anyway looks + * identical from here. Only a reply to a command we issued shows the command channel is live, and that is + * the finding the offload actually depends on. + * + * Treating the weaker evidence as the stronger one is how the false bond of 28 Aug happened: an unrelated + * completion on a shared characteristic was read as an answer to a question nobody had been asked. + */ +internal enum class UnbondedProbeEvidence { + /** Nothing decodable has arrived. */ + NONE, + + /** A valid frame arrived on a puffin notify characteristic: the strap serves them unbonded. */ + SERVES_NOTIFICATIONS, + + /** A COMMAND_RESPONSE arrived: the strap parsed a puffin command written over an unencrypted link and + * answered it. This is the evidence the offload needs. */ + ANSWERS_COMMANDS, +} + +/** The COMMAND_RESPONSE type name as `Framing.parseFrame` reports it. */ +internal const val COMMAND_RESPONSE_TYPE_NAME = "COMMAND_RESPONSE" + +/** + * Classify one parsed puffin frame as probe evidence. + * + * INTEGRITY-gated, and deliberately strict about it. A frame that does not verify is noise on a link we + * are testing precisely because we do not know whether it is allowed to carry this traffic, and counting + * noise as proof would let the probe conclude the opposite of the truth. + * + * WHAT [ok] NOW MEANS, and what that changed here. It used to say only "the envelope was well-formed" + * (start-of-frame present, minimum length) — which is why the separate `crcOk != true` condition carried + * the whole weight of the check, and why the sentence that used to stand here, "`ok` alone is an envelope + * check", was true. It is the verifier's FULL verdict now: header checksum, payload CRC32 and the exact + * declared length together. So `crcOk != true` no longer adds a rule; it is kept because it is the + * narrower statement of the two and a reader should not have to know that to trust the gate. + * + * The evidence effect DID change, in one direction only, and it is the direction the probe wants: a frame + * whose payload CRC32 verifies while its header checksum or declared length does not used to count as + * proof that the strap serves this link unbonded, and no longer does. That class is precisely what a + * confused or foreign transmitter on a shared channel produces, and the probe's output is a claim about + * the STRAP. Nothing that verified before stops counting: an intact frame satisfies both spellings. + */ +internal fun unbondedProbeEvidenceOf( + ok: Boolean, + crcOk: Boolean?, + typeName: String?, +): UnbondedProbeEvidence = when { + !ok || crcOk != true || typeName.isNullOrBlank() -> UnbondedProbeEvidence.NONE + typeName == COMMAND_RESPONSE_TYPE_NAME -> UnbondedProbeEvidence.ANSWERS_COMMANDS + else -> UnbondedProbeEvidence.SERVES_NOTIFICATIONS +} + +/** + * Keep the strongest evidence seen on this link. + * + * The probe's verdict must not depend on which frame happened to arrive last. A REALTIME_DATA burst + * following the COMMAND_RESPONSE would otherwise walk the conclusion back down to the weaker finding. + */ +internal fun strongerProbeEvidence( + a: UnbondedProbeEvidence, + b: UnbondedProbeEvidence, +): UnbondedProbeEvidence = if (b.ordinal > a.ordinal) b else a + +/** Announced before stage 1 so a capture shows the probe starting, not just its outcome. */ +internal fun unbondedProbeStartLine(): String = + "Unbonded offload probe: subscribing the puffin notify chars (fd4b0003/4/5/7) on a link with no" + + " CLIENT_HELLO. Never attempted on a healthy link before — an insufficient-authentication status" + + " here means the offload requires an encrypted bond and #1635 ends it (experimental, #1635)." + +/** + * Stage 1's refusal — the answer the probe exists to get. + * + * Named as a finding rather than an error: it settles for Android what only a comment has claimed, and it + * is latched per device so the strap says it once instead of on every reconnect. + */ +internal fun puffinSubscribeRefusedLine(uuid: String, status: String): String = + "Unbonded offload probe: subscribe of $uuid failed $status. If this is an insufficient-authentication" + + " or -encryption status, the puffin notify chars need an encrypted link, the historical offload" + + " cannot be reached without a bond, and a 5/MG that refuses SMP can never sync history (#1635)." + +/** + * Stage 1 ended because the LINK did, before any subscribe was confirmed or refused. + * + * The outcome the probe had no verdict for, and the field capture is unambiguous about how much that + * matters: 16 probe starts, 0 verdicts of any kind, 0 confirmed subscribes, 0 refusals, and the link + * dying 10.8s into every connect — roughly three seconds after the CCCD writes went out. With no verdict + * the silence budget never advanced, so the probe re-ran on every reconnect indefinitely. That is exactly + * the unbounded retry [shouldProbeUnbondedOffload]'s own doc claims this design prevents, reintroduced by + * an unhandled exit. + * + * It is also a FINDING, not just a gap. No callback and no ATT error, then a teardown about three seconds + * later, is the CLIENT_HELLO's signature — the same silent elevate-and-drop, on the same service. It does + * not prove the puffin characteristics require encryption, but it is what that would look like from here, + * and it says plainly that the offload is not reachable on this strap without a bond. + * + * Charged to the silence budget, because "the link will not survive being asked" is a stronger reason to + * stop asking than a strap that merely stayed quiet. + * + * #1804: this line is now ONLY for strap-side drops (GATT_CONN_TIMEOUT, supervision timeout). A LOCAL + * teardown (status=22) is handled by [unbondedProbeLinkLostLocalTeardownLine] instead, which does NOT + * charge the budget — a local teardown is our own stack ending the link, not a strap verdict. + */ +internal fun unbondedProbeLinkLostLine( + uptimeMs: Long, + confirmedSubscribes: Int, + total: Int, +): String = + "Unbonded offload probe: the link dropped ${uptimeMs}ms into this connect with $confirmedSubscribes" + + " of $total puffin subscribes confirmed and no refusal — no callback and no ATT error, then a" + + " teardown. That is the CLIENT_HELLO's own signature on the same service, so the offload is not" + + " reachable on this strap without a bond (#1635)." + +/** + * Stage 2 ended because the link did, with GET_CLOCK already on the wire. + * + * Deliberately NOT the same line as [unbondedProbeLinkLostLine], and that distinction is the whole point. + * A stage-1 link loss carries a finding — the subscribes drew no callback and no ATT error before the drop, + * which is the CLIENT_HELLO's signature. A stage-2 link loss carries none: the subscribes LANDED, the + * transport was open, and the strap was still within its window to answer when the link went. Reporting + * that as evidence the strap will not answer would be the same conflation this probe keeps having to + * unpick. + * + * It still spends a budget attempt, because an inconclusive link is not a reason to retry forever — that + * is the hole this exists to close, and leaving stage 2 uncounted would reopen it one stage later. + * + * #1804: this line is now ONLY for strap-side drops. A LOCAL teardown (status=22) is handled by + * [unbondedProbeLinkLostLocalTeardownLine] instead, which does NOT charge the budget. + */ +internal fun unbondedProbeLinkLostAskingLine(uptimeMs: Long, waitedMs: Long): String = + "Unbonded offload probe: the link dropped ${uptimeMs}ms into this connect, ${waitedMs}ms after" + + " GET_CLOCK went out. The subscribes had landed, so the transport was open and the strap was still" + + " inside its window to answer — this link settles nothing either way (#1635)." + +/** + * #1804: was the probe's link lost by US or by the strap? + * + * A local teardown (`GATT_CONN_TERMINATE_LOCAL_HOST`, status 22) is OUR stack ending the link — it is + * not a strap verdict, and counting it as one is the defect this exists to close. The field capture + * that surfaced this had three probe attempts end at 10776/10761/10787 ms — a 30 ms spread that is a + * timer, not a radio event — with `status=22` and `via=unknown` on every one. The probe concluded + * the strap refuses the offload unbonded from a link OUR side tore down, and latched it permanently. + * + * Only a strap-side drop (`GATT_CONN_TIMEOUT`, supervision timeout) or an ATT error is an answer + * about the strap. A local teardown is inconclusive: it must not consume a budget attempt and must + * not advance the silence counter. + * + * Pure so the classification is unit-testable without a BLE stack, like every other judgement in + * this file. The status constants are kept as raw ints here (matching `WhoopBleClient`'s private + * constants) so this file does not depend on `BluetoothGatt` and can run under plain JVM tests. + */ +internal fun unbondedProbeLinkLostIsLocalTeardown( + status: Int, +): Boolean { + // GATT_CONN_TERMINATE_LOCAL_HOST = 0x16 = 22. Any local teardown, whether we know which path + // did it or not, is inconclusive about the strap. `via=unknown` is the case the field capture + // surfaced; `via=bondWatchdog` etc. are known local paths and equally not strap verdicts. + // + // The origin is NOT a parameter here: it does not affect the verdict, and a parameter that + // cannot change the answer invites the next reader to believe it can. The origin IS carried by + // `unbondedProbeLinkLostLocalTeardownLine`, which is where it is actually used. + return status == 22 +} + +/** + * #1804: the line for a probe link lost to a LOCAL teardown, which is inconclusive about the strap. + * + * Distinct from [unbondedProbeLinkLostLine] (stage 1, strap-side drop — carries the CLIENT_HELLO + * signature) and [unbondedProbeLinkLostAskingLine] (stage 2, strap-side drop — carries no finding). + * This one carries no finding EITHER way and does NOT charge the silence budget, because the link + * was ended by our own stack, not by the strap. Charging the SILENCE budget would spend it on a + * question that was never asked of the strap, which is exactly the false negative that latched the + * probe permanently on the reporting install. + * + * It DOES charge the INCONCLUSIVE budget ([UNBONDED_PROBE_MAX_INCONCLUSIVE_LINKS]), which has its + * own larger cap. A local teardown is weaker evidence than silence, but it cannot be unbounded: on + * the strap this fix was written for, EVERY attempt was a local teardown, and without a cap the + * probe would re-run indefinitely with no path to a conclusion. + */ +internal fun unbondedProbeLinkLostLocalTeardownLine( + uptimeMs: Long, + stage: Int, + localTeardownOrigin: String?, +): String { + val stageDesc = if (stage == 1) "while subscribing the puffin notify chars" else "after GET_CLOCK went out" + val origin = localTeardownOrigin ?: "unknown" + return "Unbonded offload probe: the link was terminated locally ${uptimeMs}ms into this connect" + + " $stageDesc (via=$origin). A local teardown is not a strap verdict, so this link is" + + " inconclusive and does not consume a silence-budget attempt (#1804, #1635)." +} + +/** + * How many times the probe may stand aside for the DIS chain before going anyway. + * + * A cap rather than an open wait, because the chain has exits that never reach its terminal — a refused + * read, or a strap that stops answering part-way — and a probe waiting on a flag nobody will clear would + * simply never run. Eight checks at a second each, then it takes its chances and the trace says which + * happened. + */ +internal const val UNBONDED_PROBE_MAX_DEFERRALS = 8 + +/** + * Should the probe wait rather than start? + * + * Pure so the decision is testable without a GATT stack, like every other judgement in this file. It was + * briefly inline in the client, which is how the same class of gap reached #1755: the behaviour was + * argued for in a comment and asserted nowhere. + */ +internal fun unbondedProbeShouldWaitForDis( + disChainInFlight: Boolean, + deferralsSoFar: Int, + cap: Int = UNBONDED_PROBE_MAX_DEFERRALS, +): Boolean = disChainInFlight && deferralsSoFar < cap + +/** + * The probe is holding off because the unbonded DIS chain still has the GATT queue. + * + * Logged once per link rather than per deferral: the useful fact is that it waited at all, and a line a + * second for eight seconds would bury it. Without this the probe simply appears late in a capture with + * no reason given, which is the shape of problem this whole area keeps producing. + */ +internal fun unbondedProbeWaitingForDisLine(): String = + "Unbonded offload probe: waiting for the DIS read chain to finish — they share one GATT queue, and" + + " starting on top of it makes every CCCD write come back busy (#1635)." + +/** + * The wait ran out and the probe went anyway. + * + * Not a failure. The DIS chain has exits that never reach its terminal — a refused read, or a strap that + * stops answering part-way — so a probe that waited on the flag forever would never run at all. Saying + * which of the two happened is the point: a probe that waited the full budget and then found a busy queue + * is a different capture from one that started cleanly. + */ +internal fun unbondedProbeStoppedWaitingLine(deferrals: Int): String = + "Unbonded offload probe: the DIS chain has not finished after $deferrals checks — starting anyway." + + " If the subscribes come back busy, that queue is why, not the strap (#1635)." + +/** + * Stage 2's question, logged so the wait that follows is attributable to it. + * + * Carries the CONFIRMED count, not the attempted one. A CCCD write can also end by being abandoned after + * its busy retries, which reaches the same completion path as four clean subscribes — so a line that said + * "subscribed" flatly would overstate a partial result in the one log this experiment exists to produce. + */ +internal fun unbondedProbeAskingLine(subscribed: Int, total: Int, waitMs: Long): String = + "Unbonded offload probe: $subscribed of $total puffin notify chars subscribed — the strap serves them" + + " on an unencrypted link. Sending GET_CLOCK (read-only, changes nothing on the strap) and" + + " listening ${waitMs}ms for a COMMAND_RESPONSE, which would be the first proof it acts on puffin" + + " commands unbonded (#1635)." + +/** + * Stage 1 ended with nothing confirmed and no refusal to name. + * + * Distinct from both other stage-1 outcomes on purpose. A refusal is the strap's answer; this is the + * absence of one — every write was abandoned by our own stack before the strap ever ruled — and reporting + * it as either a refusal or a success would put a fact in the capture that was never established. + */ +internal fun unbondedProbeNoSubscriptionsLine(total: Int): String = + "Unbonded offload probe: none of the $total puffin notify chars completed their subscribe, and none" + + " was refused either — the writes were abandoned locally, so the strap never ruled on them. No" + + " question asked; this link proves nothing either way (#1635)." + +/** + * Stage 2's silence. + * + * Distinguished from a refusal on purpose: the subscriptions landed, so the transport is open and the + * strap simply did not answer. That is a different fact about the strap than "it declined the link", and a + * capture that conflated them would send the next reader after the wrong thing. + */ +internal fun unbondedProbeSilentLine(waitedMs: Long, sawNotifications: Boolean): String = + "Unbonded offload probe: no COMMAND_RESPONSE after ${waitedMs}ms. The subscriptions were accepted" + + (if (sawNotifications) " and frames did arrive on them," else " but nothing arrived on them,") + + " so the transport is open and the strap is not answering commands on an unencrypted link." + + " Not retrying on this strap (#1635)." + +/** Stage 2's success — the finding that unlocks the rest. */ +internal fun unbondedProbeAnsweredLine(): String = + "Unbonded offload probe: COMMAND_RESPONSE received — the strap ACTS on puffin commands over an" + + " unencrypted link. Clocking the strap and requesting the historical offload (#1635)." + +/** + * The expectation to set the moment the probe succeeds, not after the offload returns empty. + * + * An un-clocked 5/MG is hardware-known not to persist sensor data to flash at all ("RTC timestamp … is + * invalid; not saving data to flash", #78 fork). A strap that has never completed a handshake has never + * been clocked by NOOP, so the honest expectation is that an offload which finally runs may find little or + * nothing banked — and that this buys FUTURE syncing rather than recovering the backlog. GET_DATA_RANGE + * answers it directly, which is why it is the stage before the transfer rather than after it. + */ +internal fun unbondedProbeBacklogCaveatLine(): String = + "Unbonded offload probe: note that this strap has never been clocked by NOOP, and an un-clocked 5/MG" + + " does not save sensor data to flash — so GET_DATA_RANGE may legitimately report little or nothing" + + " banked. That would mean history works from now on, not that the probe failed (#1635)." 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 94fe81b2ad..c068853602 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -26,17 +26,21 @@ import android.os.SystemClock import android.util.Log import com.noop.NoopApplication import com.noop.data.HrRow +import com.noop.data.InsertCounts import com.noop.data.RrRow +import com.noop.data.EventEntry +import com.noop.data.StandardHrMapping import com.noop.data.StreamBatch -import com.noop.data.RawImuSampleEntity import com.noop.data.StreamPersistence import com.noop.protocol.Whoop5RawImu +import com.noop.testcentre.ImuSessionFileStore import com.noop.data.WhoopRepository import com.noop.protocol.AlarmPayload import com.noop.protocol.DYN_ACCEL_STILL_THRESHOLD_G import com.noop.protocol.BackfillCaptureJsonl import com.noop.protocol.BackfillCaptureRecord import com.noop.protocol.BackfillCaptureSummary +import com.noop.protocol.wireName import com.noop.protocol.CommandNumber import com.noop.protocol.FeatureFlagWriteGate import com.noop.protocol.R22DisableReport @@ -54,12 +58,14 @@ import com.noop.protocol.Reassembler import com.noop.protocol.Whoop5Variant import com.noop.protocol.RebootProbeVariant import com.noop.protocol.Streams +import com.noop.protocol.StandardHrContact import com.noop.protocol.Whoop5Config import com.noop.protocol.extractStreams import com.noop.protocol.WhoopGattServiceFamily import com.noop.protocol.whoopGattScanDecision import com.noop.analytics.Baselines import com.noop.analytics.BatterySocLine +import com.noop.analytics.ConnectionReadout import com.noop.analytics.IntelligenceEngine import com.noop.analytics.NapDetector import com.noop.analytics.NapPrefs @@ -90,6 +96,10 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.util.UUID +import java.io.BufferedOutputStream +import java.io.DataOutputStream +import java.io.File +import java.io.FileOutputStream import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.atomic.AtomicInteger @@ -156,11 +166,48 @@ data class LiveState( * reconnects (or the settle timeout gives up). With `!connected` it drives the Devices card's * transient "Reconnecting…" pill. Twin of macOS LiveState.rebootInProgress. */ val rebootInProgress: Boolean = false, - /** Charging flag from BATTERY_LEVEL events — wire observation: u8 bit0 (4.0 @26 / 5.0 @30, - * ~every 8 min on captured links). Flag only; battery % keeps its family source (#77). - * Cleared on disconnect so a stale flag can't outlive the link. Twin of macOS - * LiveState.charging. */ + /** Charging flag. Two sources, and they mean different things (#1935). + * + * The authority is BATTERY_LEVEL — wire observation: u8 bit0 (4.0 @26 / 5.0 @30, ~every 8 min on + * captured links). That is a LEVEL signal from the strap's own gauge: every live battery event + * rewrites this flag, whatever it was. + * + * On a 5/MG it is ALSO set by BATTERY_PACK_CONNECTED(21) and cleared by BATTERY_PACK_REMOVED(22), + * which is a latency win — 21 leads CHARGING_ON(7) by up to ~17 s in captures, so the pill responds + * when the pack goes on. But 21 means A PACK WAS ATTACHED, not that charging began. They diverge on + * a depleted pack or a poor contact: 21 fires, 7 never does, and this reads true while nothing + * charges. + * + * THAT STATE IS BOUNDED, which is why it is documented rather than split. It does not last until 22: + * the next live BATTERY_LEVEL overwrites it from the strap's own GAUGE, so the window is about one + * battery cadence, and the gauge always gets the last word. + * + * THE GAUGE ONLY GETS THE LAST WORD BECAUSE NOTHING ELSE REPEATS. That is a real constraint, not an + * observation (#1935): the pushed pack-info event (109) used to write charging=true too, keyed on + * pack PRESENCE plus a plausible SoC, as an anti-staleness half for a missed attach edge. It repeats + * every couple of minutes, so it outran the ~8 min BATTERY_LEVEL and a flat or badly seated pack read + * "charging" for its whole attachment instead of self-correcting. It now writes [packSocPct] alone, + * which is what the anti-staleness job actually wanted. So: an EDGE may set this flag (7, 21, 22), a + * repeating presence signal must not, or the gauge stops being able to correct it. + * + * It matters beyond the pill. [WhoopBleClient.idleThrottleActive] reads this flag and gates THREE + * levers, not one: the low-battery offload cadence, the GATT connection-priority throttle, and the + * continuous-capture pause behind the user's own "Pause HRV capture" percentage. So a strap on a flat + * pack can skip low-battery throttling and keep background capture running after the user asked for + * it to stop, for that window. [WhoopBleClient.batteryPollDue] also reads it, polling every tick + * instead of every other, which is harmless and arguably wanted with a pack on. + * + * Flag only; battery % keeps its family source (#77). Cleared on disconnect so a stale flag can't + * outlive the link. Twin of macOS LiveState.charging. */ val charging: Boolean? = null, + /** Battery-pack charge, tenths-of-a-percent precision, from the pushed pack event (109) payload. + * 5/MG only — a WHOOP 4.0 has no pack fuel gauge (its pack reads as a VOLTAGE via opcode 98, a + * different quantity). Null until the first pack event lands, cleared on BATTERY_PACK_REMOVED and + * on disconnect, so a removed pack or a dropped link can never leave a stale charge on the card. + * + * There is deliberately no separate "pack attached" flag: nothing needs one. Non-null here already + * means a pack is attached and reporting, because the strap only sends the event while one is on. */ + val packSocPct: Double? = null, /** Wrist-wear from WRIST_ON/WRIST_OFF events. Defaults TRUE to match the macOS LiveState (Swift * parity) — assume worn until the strap says otherwise. (Was false, which made the UI show * "Worn: Off" forever when no WRIST_ON event arrived — issue #18.) */ @@ -183,6 +230,16 @@ data class LiveState( /** True while a historical offload session is running, so screens can say "Syncing strap * history…" instead of presenting half-loaded data as final (#77). */ val backfilling: Boolean = false, + /** #1164 — true when the strap reports banked records newer than our local HR frontier (the strap + * has data we haven't ingested yet), even when no offload is actively running. Set from the + * GET_DATA_RANGE newest vs. the collector's latest HR sample, with the same 5-min + * `behindGapSeconds` the auto-continue predicate uses. Cleared on disconnect so a stale "pending" + * can't outlive the link. Drives the Today Rest "Pending sync" state so a provisional score isn't + * shown as final before the full night is offloaded. Twin of the Swift LiveState.historyPendingSync. */ + val historyPendingSync: Boolean = false, + /** True while a post-history scoring pass is turning the newly stored raw streams into sleep and + * daily metrics. Kept separate from [backfilling] so Sleep can distinguish downloading from calculating. */ + val analyzingHistory: Boolean = false, /** Chunks acked during the current offload session — an honest progress signal (total pending is * unknowable from the protocol, so no percent). Republished every ~10 chunks: the foreground * service re-posts its notification on EVERY LiveState emission, so per-chunk would spam it. */ @@ -194,6 +251,14 @@ data class LiveState( /** Set when an offload ended abnormally (strap went quiet mid-sync / idle-watchdog fired), so a * stalled history download isn't silent. Cleared on the next successful HISTORY_COMPLETE. (PR #85) */ val lastSyncError: String? = null, + /** True once this strap can actually hand over history — the UI mirror of the client's + * `connectHandshakeDone`, which [beginBackfill] already requires before it will request an offload. + * + * Exposed because [bonded] is NOT that condition and reads true too early: the live-HR path sets it + * for a 5/MG that has never completed a handshake, so Today offered a pull-to-sync that was accepted + * by the gesture and then refused deeper down, silently. Gating on this makes the control unavailable + * exactly when the sync would have been declined anyway — never when it would have run. */ + val historyReady: Boolean = false, /** Set when a connect attempt fails because the strap wiped its Bluetooth bond — a firmware reset, * or the official WHOOP app re-bonding it. The OS still holds a now-stale bond, so retrying the * direct connect just re-fails. Carries an actionable forget+re-pair guide; cleared on the next @@ -392,6 +457,22 @@ class RealGattOps(private val gatt: BluetoothGatt) : GattOps { gatt.setPreferredPhy(txPhy, rxPhy, phyOptions) } +/** Foreground ground-truth diagnostics. Kept separate from [LiveState] so production UI/state stays clean. */ +data class GroundTruthImuStatus( + val sessionId: String? = null, + val requested: Boolean = false, + val packets: Long = 0, + val bytes: Long = 0, + val lastPacketAtMs: Long? = null, + val note: String = "Not started", +) + +internal fun standardHrBufferReachedFlushThreshold( + hrCount: Int, + rrCount: Int, + contactCount: Int, +): Boolean = hrCount + rrCount + contactCount >= 30 + class WhoopBleClient( private val context: Context, /** @@ -407,6 +488,11 @@ class WhoopBleClient( * ("my-whoop") — which matches the Swift default and the rest of the Android app, so behaviour * is unchanged today while the registry takes over as the single source of the active id. * + * `@Volatile` because #1881 added a SECOND cross-thread writer: [adoptSourceIdentity] resolves the + * connected strap's row on [ioScope] (the Room registry read is `suspend`) and re-points from there, + * while the persist sites read this on the BLE handler thread. Without it that thread could keep + * reading the pre-connect value and file the link's rows under the wrong device — the exact bug. + * * MUTABLE (multi-WHOOP, MW-3): [setActiveDeviceId] re-points it so a WHOOP→WHOOP switch attributes * new samples to the newly-active WHOOP immediately, without waiting for a relaunch. The single-WHOOP * path NEVER reassigns it (the coordinator only calls [setActiveDeviceId] for a non-legacy WHOOP), so @@ -414,6 +500,7 @@ class WhoopBleClient( * sites + the analyze pass read this field directly; the [Backfiller] captured its own copy at * construction, so [setActiveDeviceId] re-points that too (see there). */ + @Volatile private var deviceId: String = DEFAULT_DEVICE_ID, /** Durable trim-cursor store for the offload safe-trim watermark (see [Backfiller]). */ private val cursorStore: TrimCursorStore = PrefsTrimCursorStore(context), @@ -430,10 +517,14 @@ class WhoopBleClient( * call site unchanged. */ private val gattOpsFactory: (BluetoothGatt) -> GattOps = ::RealGattOps, + /** Fire-and-forget notification after a true HISTORY_COMPLETE only. The sink must only enqueue. */ + private val successfulOffloadSink: () -> Unit = {}, ) { companion object { private const val TAG = "WhoopBleClient" + internal fun shouldNotifySuccessfulOffload(reason: String, bankedRows: Boolean): Boolean = + reason == "HISTORY_COMPLETE" || (reason == "timeout" && bankedRows) /** * Cap on the in-app strap-log ring buffer (for the "Share strap log" diagnostics export). * Raised from the old ~1h (2,000 lines) to retain a rolling ~24h of activity (#510 — @@ -628,6 +719,11 @@ class WhoopBleClient( else -> BluetoothGatt.CONNECTION_PRIORITY_BALANCED } + /** Bounded 100 Hz work gets HIGH independently of the global experiment. A completed capture + * only holds it while an offload is actively repairing missing seconds. */ + fun rawCaptureHighPriority(captureActive: Boolean, backfilling: Boolean, needsRepair: Boolean): Boolean = + captureActive || (backfilling && needsRepair) + /** * The throughput summary for one offload burst (#1007), pure so it is testable without a BLE stack. * @@ -679,7 +775,14 @@ class WhoopBleClient( * strap is DISCHARGING at/below [thresholdPct]. The * phone's own Battery Saver deliberately does NOT trigger it — power saving is about the strap's * charge, not the phone's. A charging strap never throttles. The threshold is its own hysteresis - * (battery % moves slowly, so a boundary crossing flips at most once per point). */ + * (battery % moves slowly, so a boundary crossing flips at most once per point). + * + * [LiveState.charging] is not purely "is charging" (#1935): on a 5/MG it is also set on pack + * ATTACH, so a depleted or badly-seated pack reads true while nothing charges, and this gate then + * stays off. The window is bounded — the next BATTERY_LEVEL rewrites the flag from the strap's own + * gauge — and the reasoning for accepting that rather than splitting the state is on that field. + * Read it before adding a trend check here: making this wait for a rising gauge would delay + * throttle release on every honest attach to close an edge that already closes itself. */ fun idleThrottleActive(batteryPct: Int, charging: Boolean, thresholdPct: Int): Boolean = thresholdPct > 0 && !charging && batteryPct <= thresholdPct @@ -842,6 +945,17 @@ class WhoopBleClient( /** 5/MG fail-open gate: how long to wait for a GET_DATA_RANGE SUCCESS before requesting * history anyway (real hardware sometimes swallows the first range query, #78 fork). */ private const val DATA_RANGE_GATE_MS = 2_000L + + /** #1635: how long the unbonded probe listens for a COMMAND_RESPONSE to its GET_CLOCK. + * Generous next to [DATA_RANGE_GATE_MS] on purpose — a strap that has never held a session may be + * slower than one mid-sync, and the cost of waiting is a few seconds on a link that is already + * stable, while the cost of asking too early is a false "does not answer" that closes #1635 the + * wrong way. */ + private const val UNBONDED_PROBE_REPLY_WAIT_MS = 8_000L + + /** #1635: how long the probe waits between checks while the unbonded DIS chain is still running. */ + private const val UNBONDED_PROBE_DEFER_MS = 1_000L + /** 5/MG zero-frame retry: pause before re-requesting history when a session timed out having * produced nothing (the first request after connect can go entirely unanswered). */ private const val WHOOP5_HISTORY_RETRY_DELAY_MS = 700L @@ -1025,6 +1139,17 @@ class WhoopBleClient( /** Stream gone quiet this long (but not yet stall) ⇒ re-subscribe in case a CCCD silently dropped. */ private const val KEEPALIVE_QUIET_MS = 45_000L + /** + * #1865: how long live HR may be absent, while we believe the stream is armed, before a 5/MG gets + * one re-arm. + * + * Sits between the 45 s quiet threshold and the 600 s bounce fuse on purpose. The 0x2A37 profile + * genuinely lulls at rest — that lull is why #1414 widened the fuse to 10 minutes — so a shorter + * value would re-arm a perfectly healthy stream, while waiting for the fuse means never recovering + * at all: the fuse watches ANY inbound data, and a strap answering battery polls never trips it. + */ + private const val REALTIME_HR_STALL_MS = 300_000L + /** A CCCD write can transiently return BUSY if the stack slot hasn't freed yet; retry the same * subscribe a few times (short backoff) before giving up, rather than dropping the stream. */ private const val CCCD_RETRY_DELAY_MS = 60L @@ -1184,6 +1309,28 @@ class WhoopBleClient( else -> "status=$status" } + /** + * #1865: whether a 5/MG's realtime stream looks LAPSED and should get its one re-arm. + * + * Pure so the decision is testable — `keepAliveFire` itself needs a live GATT link, which is why + * the drop-recovery decision next door was extracted the same way. + * + * Every clause earns its place: + * - `wantsRealtime`: never arm a stream nobody asked for. + * - `realtimeArmed`: if we do NOT believe it is armed, `reconcileRealtime` has a real edge and + * sends the toggle itself. This path exists only for the case it cannot see. + * - `!reArmedSinceHr`: one write per stall episode, not one per 30 s tick. + * - the threshold: the 0x2A37 profile legitimately lulls at rest, so this must sit well past a + * normal quiet spell. + */ + fun shouldReArmLapsedRealtime( + wantsRealtime: Boolean, + realtimeArmed: Boolean, + reArmedSinceHr: Boolean, + hrSilentMs: Long, + stallMs: Long = REALTIME_HR_STALL_MS, + ): Boolean = wantsRealtime && realtimeArmed && !reArmedSinceHr && hrSilentMs > stallMs + fun shouldReArmRealtimeAfterDrop(droppedCmd: CommandNumber?): Boolean = droppedCmd == CommandNumber.TOGGLE_REALTIME_HR @@ -1249,6 +1396,12 @@ class WhoopBleClient( charging = null, strapFirmware = null, historyLayoutVersion = null, pairingHint = null, scanning = false, statusNote = null, + // Pack readout dies with the link, the same rule as [charging]: a card still showing + // "Pack 56.9%" for a strap we are no longer talking to is a stale reading, not a stale + // pill. This clear is also why no expiry timer is needed — the only way to miss a pack + // removal is to be disconnected for it, and reconnecting starts from null either way. + // Event 109 re-establishes the reading within a couple of minutes if a pack is still on. + packSocPct = null, ) /** #1466: did this offload hand over anything at all? Acked chunks, persisted rows, or deep packets. @@ -1447,6 +1600,119 @@ class WhoopBleClient( /** #690: persisted previous body-location payload hex, so a new capture can diff against it. */ private const val KEY_690_PREV_PAYLOAD = "noop.690.prevPayload" + /** Sentinel value of [batteryPackProbe] between sending the cmd-151 probe and its reply landing. */ + const val WAITING_BATTERY_PACK_PROBE = "__waiting__" + + /** How long to wait for a cmd-151 COMMAND_RESPONSE before treating the silence as "no reply". + * Same window as the #690 probe — long enough to ride out a mid-flight sync, short enough that + * the dialog does not hang. */ + const val BATTERY_PACK_PROBE_TIMEOUT_MS = 8_000L + + /** + * Blank the pack's six address bytes inside the raw frame dump, leaving every other byte + * readable. [cmdOff] + 5 is the address offset the decoder uses; [decoded] gates the edit so a + * frame that did NOT decode keeps all of its bytes, offsets being the open question there. + */ + internal fun maskPackAddrInDump(rawHex: String, cmdOff: Int, decoded: Boolean): String { + if (!decoded) return rawHex + val from = (cmdOff + 5) * 2 + val to = from + 12 + if (from < 0 || to > rawHex.length) return rawHex + return rawHex.substring(0, from) + "••••••••••••" + rawHex.substring(to) + } + + /** + * Mask the pack's six address bytes inside the event-census `payload=` hex, and nothing else. + * Event 109 (the pushed pack record, see BatteryPackInfo) carries the pack's BT address at + * payload offset 5..10. `redactStrapLogPii` lifts an ASCII serial out of a hex run but has no + * rule that reaches a colon-less address, so without this the census publishes the address in + * every shared strap log. Same rule as [maskPackAddrInDump]: blanked only once `decodeEventFrame` + * has confirmed a present pack at the expected layout — for any other event, or an undecodable + * 109, the payload stays whole, because there the bytes ARE the evidence the census exists for. + */ + internal fun maskPackAddrInEventPayload(payloadHex: String, frame: ByteArray): String { + if (frame.size <= 10 || (frame[10].toInt() and 0xFF) != com.noop.protocol.BatteryPackInfo.PACK_INFO_EVENT) return payloadHex + val info = com.noop.protocol.BatteryPackInfo.decodeEventFrame(frame) + if (info == null || !info.present) return payloadHex + val from = 5 * 2 + val to = from + 12 + if (to > payloadHex.length) return payloadHex + return payloadHex.substring(0, from) + "••••••••••••" + payloadHex.substring(to) + } + + /** + * First and last octet of a pack's BT address, the rest masked — the same shape + * `redactStrapLogPii` gives a colon-formatted MAC, applied at the SOURCE because this report + * also reaches a copy-to-clipboard dialog that the scrubber never sees. + */ + internal fun maskPackBtAddr(btAddr: String?): String { + if (btAddr.isNullOrEmpty()) return "" + if (btAddr.length != 12) return "" + return "${btAddr.take(2)}:••:••:••:••:${btAddr.takeLast(2)}" + } + + /** + * Format a cmd-151 COMMAND_RESPONSE into a readable report for the Devices dialog + strap log. + * Pure, so it is unit-testable without a strap. [cmdOff] is the response-command byte offset + * (10 on 5/MG; a WHOOP 4.0 has no pack command at all and is not expected to answer). + * + * Reports BOTH readings of the SoC word deliberately. The tenths-of-a-percent interpretation is + * an UNVALIDATED CANDIDATE re-derived from two captured frames, and the same raw value is + * equally consistent with mAh remaining; the committed "absent" vector is all zeros, so the pair + * confirms the present-flag discriminator and says nothing about the unit. Asserting one reading + * here would be a diagnostic claiming more than it can attribute, so the raw word is shown beside + * both and the operator settles it with a second capture at a different pack level. + */ + internal fun formatBatteryPackProbe(frame: ByteArray, cmdOff: Int): String { + val sb = StringBuilder() + val info = com.noop.protocol.BatteryPackInfo.decode(frame, cmdOff) + sb.append("GET_BATTERY_PACK_INFO (151) response, ${frame.size} bytes, cmdOff=$cmdOff\n") + // The dump is the evidence, so it stays whole EXCEPT the six address bytes, and only once + // `decode` has confirmed the layout. `redactStrapLogPii` lifts the serial out of a hex run + // but has no rule that can reach a colon-less address, so without this the pack's MAC ships + // in both sinks even after scrubbing. Masked at a KNOWN offset only: when decode returned + // null the offsets are precisely what is in question, and blanking a guessed range there + // would destroy the evidence the operator opened this probe to read. + val rawHex = frame.joinToString("") { "%02x".format(it) } + sb.append("raw: ${maskPackAddrInDump(rawHex, cmdOff, info != null && info.present)}\n\n") + if (info == null) { + sb.append("DID NOT DECODE as a 151 SUCCESS response at this offset.\n") + sb.append("Either the reply is not a 151 response, or its result byte is not SUCCESS, or\n") + sb.append("the frame is shorter than the candidate layout needs. The raw hex above is the\n") + sb.append("evidence — the opcode may be right and the OFFSETS wrong.") + return sb.toString() + } + if (!info.present) { + sb.append("present = FALSE — the strap reports NO pack attached.\n") + sb.append("If a pack IS physically attached right now, the present-flag offset is wrong.") + return sb.toString() + } + sb.append("present = TRUE\n") + // logSafe + a masked address, NOT the raw pair. This report goes to the SHAREABLE strap log + // AND to a dialog with a copy button, and neither identifier survives contact with the + // scrubber: `redactStrapLogPii` keys the serial rule on a literal "WHOOP " prefix (a bare + // `serial = BB5AP…` matches nothing) and keys the MAC rule on COLONS, which `btAddr`'s + // colon-less `joinToString("")` never produces. The pack-log line in + // `handleCommandResponse` already learned this and says so; a probe that prints them raw + // would walk both straight back out. Three characters tell two packs apart, and the first + // and last octet tell two addresses apart, which is all a diagnostic needs. + sb.append("serial = ${com.noop.data.WhoopSerialIdentity.logSafe(info.serial)}\n") + sb.append("bt addr = ${maskPackBtAddr(info.btAddr)}\n") + // Read the SoC word straight from the frame rather than reconstructing it from the decoded + // Double — decode() already bounds-checked this offset when it returned present=true, and a + // float round-trip could land the raw value one off. + val rawWord = (frame[cmdOff + 27].toInt() and 0xFF) or ((frame[cmdOff + 28].toInt() and 0xFF) shl 8) + sb.append("SoC raw word = $rawWord\n") + sb.append(" read as tenths-of-a-percent: ${rawWord / 10.0}%\n") + sb.append(" read as-is (e.g. mAh): $rawWord\n") + if (rawWord > 1000) { + sb.append(" NOTE raw > 1000, so it CANNOT be tenths of a percent — this is not SoC%.\n") + } + sb.append("\nCompare against the pack's actual level, then capture again at a clearly\n") + sb.append("different level: a reading that tracks the gauge and stays <= 1000 is SoC%.") + return sb.toString() + } + /** #761: sentinel value of [featureFlagProbe] while the read-only enumeration walk is running. */ const val WAITING_FEATURE_FLAG_PROBE = "__waiting__" @@ -1758,11 +2024,22 @@ class WhoopBleClient( // MARK: Published state — the single source of truth the UI observes. Seeded with the PERSISTED // last-sync time (PR #556 reimpl) so a freshly-recreated client doesn't show "Never" when this // install has actually synced before; a 0 (never) leaves it null, unchanged. - private val _state = MutableStateFlow( - LiveState(lastSyncAt = NoopPrefs.lastSyncAt(context).takeIf { it > 0L }), - ) + // + // Deliberately seeded with NOTHING. The value belongs to the ACTIVE strap, and resolving which strap + // that is needs the registry, which is suspend — so [seedLastSyncFromActiveStrap] fills it in a moment + // later and the screens show no last-sync until it does. + // + // The obvious cheap seed, NoopPrefs.lastDevice, is the trap: it records the last strap to BOND, which + // on a two-strap install is whichever one was worn most recently, not the one the screens are scoped + // to. Seeding from it would put the 4.0's sync time on a 5/MG's Today screen — the exact bug this + // change exists to remove, surviving inside the fix for it. A blank moment is honest; another strap's + // timestamp is not. + private val _state = MutableStateFlow(LiveState()) val state: StateFlow = _state.asStateFlow() + private val _groundTruthImuStatus = MutableStateFlow(GroundTruthImuStatus()) + val groundTruthImuStatus: StateFlow = _groundTruthImuStatus.asStateFlow() + /** Monotonic event for the in-memory/exportable strap log. Test Centre panels collect it only while * active and coalesce bursts, so a new tagged line refreshes its readout without mutating LiveState. */ private val _logRevision = MutableStateFlow(0L) @@ -1820,6 +2097,11 @@ class WhoopBleClient( private val _bodyLocationProbe = MutableStateFlow(null) val bodyLocationProbe: StateFlow = _bodyLocationProbe.asStateFlow() + // The cmd-151 battery-pack probe result (or the waiting sentinel), shown + copied in the Devices + // dialog. Read-only diagnostic: it decodes nothing into live state and gates nothing. + private val _batteryPackProbe = MutableStateFlow(null) + val batteryPackProbe: StateFlow = _batteryPackProbe.asStateFlow() + // #761: the READ-ONLY feature-flag ENUMERATION report — the flag NAMES the strap's own firmware lists // — or the waiting sentinel while the walk runs. Nothing is written to the strap to produce it. private val _featureFlagProbe = MutableStateFlow(null) @@ -1937,6 +2219,9 @@ class WhoopBleClient( * the drop-risk is confined to when the user actually wants power saving. Set on the main looper via * [setConnectionPriorityManagement]. */ @Volatile private var connectionPriorityEnabled: Boolean = false + /** Android-only transport lease for bounded 100 Hz capture and delayed history repair. CoreBluetooth + * chooses connection parameters itself and exposes no equivalent app-side request. */ + @Volatile private var rawCapturePriorityApplied: Boolean = false /** Battery-% at/below which the LOW_POWER idle throttle engages while the STRAP is discharging; * 0 = never (safe half only). * @@ -2134,7 +2419,13 @@ class WhoopBleClient( /** (Re)apply the GATT connection priority for the current link state (#477). Idempotent + cheap: OFF * or disconnected -> no BLE op. Called on connect-established and whenever offload / live-HR toggles. */ private fun refreshConnectionPriority() { - if (!connectionPriorityEnabled) return + val rawCaptureHigh = connectedFamily == DeviceFamily.WHOOP5 && rawCaptureHighPriority( + captureActive = groundTruthImuSessionId != null, + backfilling = backfilling, + needsRepair = ImuSessionFileStore(context).needsHighThroughput(deviceId), + ) + // Preserve the zero-op default path, but release a completed capture's HIGH lease once. + if (!connectionPriorityEnabled && !rawCaptureHigh && !rawCapturePriorityApplied) return val ops = gattOps ?: return // Only read the battery when the RISKY idle throttle is actually armed (threshold > 0); the SAFE // HIGH-escalation half doesn't need it, so safe-half-only mode issues no battery read. @@ -2145,7 +2436,7 @@ class WhoopBleClient( // Read the authoritative INTERNAL flags (both set synchronously on this looper), not the // published LiveState mirror, which `exitBackfilling` may update a beat later. val priority = connectionPriorityFor( - offloadActive = backfilling, + offloadActive = rawCaptureHigh || (connectionPriorityEnabled && backfilling), // #533: gated — the live stream does NOT escalate by default. See [escalateForLiveHr]: the // overnight continuous-HRV window keeps this armed for hours, and a 1 Hz stream gains nothing // from HIGH. The offload burst below is the case that actually wants the shorter interval. @@ -2157,6 +2448,7 @@ class WhoopBleClient( // binder is handled by the next real op, and skipping a priority request costs nothing. Swallow. try { ops.requestConnectionPriorityCompat(priority) + rawCapturePriorityApplied = rawCaptureHigh } catch (t: Throwable) { log("connection-priority request failed (${t.javaClass.simpleName}); skipped") } @@ -2165,6 +2457,117 @@ class WhoopBleClient( * thread (user actions) - the barrier makes a main-thread send see the current characteristic. */ @Volatile private var cmdCharacteristic: BluetoothGattCharacteristic? = null + @Volatile private var groundTruthImuCommandAllowed = false + private var groundTruthImuSessionId: String? = null + private var groundTruthImuStoppedAtMs = 0L + private var unexpectedImuStopAtMs = 0L + + /** Start the hardware-confirmed WHOOP 5 realtime IMU mode for an explicit ground-truth session. */ + @Synchronized + fun startGroundTruthImuCapture(sessionId: String): Boolean { + if (gatt == null || cmdCharacteristic == null) { + _groundTruthImuStatus.value = GroundTruthImuStatus( + sessionId = sessionId, + note = "Band not connected / command channel not ready", + ) + return false + } + if (connectedFamily != DeviceFamily.WHOOP5) { + _groundTruthImuStatus.value = GroundTruthImuStatus( + sessionId = sessionId, + note = "Realtime IMU unsupported for connected family: $connectedFamily", + ) + return false + } + groundTruthImuSessionId = sessionId + groundTruthImuStoppedAtMs = 0L + groundTruthImuCommandAllowed = true + refreshConnectionPriority() + try { + // The verified bounded raw-capture sequence is START_RAW_DATA followed by the + // realtime IMU selector. Opcode 106 alone ACKs but does not start the producer. + send(CommandNumber.START_RAW_DATA, byteArrayOf(1), withResponse = true) + send(CommandNumber.TOGGLE_IMU_MODE, byteArrayOf(1, 1), withResponse = true) + } finally { + groundTruthImuCommandAllowed = false + } + log("Ground-truth realtime IMU requested ON (session=$sessionId)") + _groundTruthImuStatus.update { it.copy(requested = true, note = "IMU requested; waiting for packets") } + return true + } + + /** Stop the realtime IMU before closing its lossless length-prefixed frame file. */ + @Synchronized + fun stopGroundTruthImuCapture() { + if (connectedFamily == DeviceFamily.WHOOP5 && gatt != null && cmdCharacteristic != null) { + groundTruthImuCommandAllowed = true + try { + send(CommandNumber.STOP_RAW_DATA, byteArrayOf(1), withResponse = true) + send(CommandNumber.TOGGLE_IMU_MODE, byteArrayOf(1, 0), withResponse = true) + } finally { + groundTruthImuCommandAllowed = false + } + } + log("Ground-truth realtime IMU requested OFF (session=${groundTruthImuSessionId ?: "none"})") + groundTruthImuSessionId = null + groundTruthImuStoppedAtMs = System.currentTimeMillis() + _groundTruthImuStatus.update { it.copy(requested = false, note = "Stopped; accepting history repair") } + // The collector closes its window before this call. A running history repair keeps HIGH; + // otherwise release now and let the next offload acquire a fresh bounded lease. + refreshConnectionPriority() + } + + @Synchronized + fun finishGroundTruthImuCapture(sessionId: String) { + if (groundTruthImuSessionId == sessionId) groundTruthImuSessionId = null + } + + @Synchronized + private fun recordGroundTruthImuFrame(frame: ByteArray) { + if (frame.size <= 8) return + val packetType = frame[8].toInt() and 0xFF + // Decode, not the transport packet type, is authoritative: history replay can wrap the same + // 100 Hz payload differently from realtime delivery. + runCatching { + val routed = ImuSessionFileStore(context).append(deviceId, frame) + if (routed > 0) { + val now = System.currentTimeMillis() + _groundTruthImuStatus.update { + it.copy( + packets = it.packets + 1, + bytes = it.bytes + frame.size, + lastPacketAtMs = now, + note = "Receiving IMU packet type $packetType", + ) + } + } + }.onFailure { + log("Ground-truth IMU append failed (${it.javaClass.simpleName})") + _groundTruthImuStatus.update { status -> status.copy(note = "Write failed: ${it.javaClass.simpleName}") } + } + } + + /** Fail-safe for a producer left armed by a crash, another client, or a lost stop write. */ + @Synchronized + private fun stopUnexpectedRealtimeImu(frame: ByteArray, replayedOffload: Boolean) { + if (connectedFamily != DeviceFamily.WHOOP5 || replayedOffload || frame.size <= 8) return + val type = frame[8].toInt() and 0xFF + if (type != 43 && type != 51) return + if (groundTruthImuSessionId != null || PuffinExperiment.from(context).isCaptureEnabled) return + val now = System.currentTimeMillis() + if (now - groundTruthImuStoppedAtMs < 3_000L || now - unexpectedImuStopAtMs < 30_000L) return + if (gatt == null || cmdCharacteristic == null) return + unexpectedImuStopAtMs = now + groundTruthImuCommandAllowed = true + try { + send(CommandNumber.STOP_RAW_DATA, byteArrayOf(1), withResponse = true) + send(CommandNumber.TOGGLE_IMU_MODE, byteArrayOf(1, 0), withResponse = true) + } finally { + groundTruthImuCommandAllowed = false + } + log("Raw IMU fail-safe: unexpected realtime packet type $type while capture was off; stop requested") + } + /** Frame reassembler for the fragmented custom notify chars (port of Reassembler). Reassigned per * connection with the detected family — WHOOP5/MG frames use a different length encoding. */ private var reassembler = Reassembler() @@ -2186,6 +2589,120 @@ class WhoopBleClient( @Volatile private var connectHandshakeDone = false + /** #1635 unbonded offload probe: stage 1 (the puffin CCCD writes) is in flight. @Volatile — set on the + * main looper, read in onDescriptorWrite on the binder thread. */ + @Volatile + private var unbondedProbeSubscribing = false + + /** #1635: stage 2 is in flight — GET_CLOCK is out and we are listening for a COMMAND_RESPONSE. + * @Volatile: cleared on the main looper, read in the notify handler on the binder thread. */ + @Volatile + private var unbondedProbeAwaitingReply = false + + /** True once the probe's skip reason has been logged on this link (#1949), so a retried start does + * not repeat it. Cleared with the rest of the per-link probe state. */ + @Volatile + private var unbondedProbeSkipLogged = false + + /** #1635: the probe has run on THIS link. Separate from [unbondedProbeAwaitingReply] because the + * keep-alive drains the same CCCD queue every 30s and would otherwise re-enter the probe's own + * completion branch for the life of the connection. */ + private var unbondedProbeStartedThisLink = false + + /** #1635: the strongest evidence seen this link, never the most recent — REALTIME_DATA follows a + * COMMAND_RESPONSE continuously once realtime is armed, and last-one-wins would report "not + * answering" about a strap that had just answered. @Volatile: written in the notify handler. */ + @Volatile + private var unbondedProbeEvidence = UnbondedProbeEvidence.NONE + + /** #1635: when stage 2's GET_CLOCK went out, so the verdict line can state the wait it actually + * waited rather than the one it intended to. */ + private var unbondedProbeAskedAtMs = 0L + + /** #1635: puffin CCCD writes the stack CONFIRMED this link. A subscribe can also end by being + * abandoned after its busy retries, which reaches the same drain-empty branch as a clean one — so the + * count, not the branch, is what says the transport is open. @Volatile: incremented in + * onDescriptorWrite on the binder thread, read on the main looper. */ + @Volatile + private var unbondedProbeSubscribed = 0 + + /** + * #1635: the probe's two scheduled steps as NAMED runnables, so [reset] can cancel them. + * + * Anonymous lambdas here were a real race, not a style point. The verdict is posted 8s out and a + * bouncing link reconnects in about 3 — so an OLD link's timer could fire while a NEW link's probe was + * mid-question, concluding it early with a "no reply" verdict it never waited for and charging the + * silence budget for a link that had answered nothing yet. Same instance for the early-success post, so + * one removeCallbacks cancels every path. + */ + private val unbondedProbeVerdictRunnable = Runnable { concludeUnbondedProbe() } + + /** #1635: the deferred probe start. Cancellable for the same reason — a stale start would arrive on a + * link that never scheduled it. Its own gates would still hold, but a timer that outlives its link is + * how surprises get in. */ + private val unbondedProbeStartRunnable = Runnable { gatt?.let { beginUnbondedOffloadProbe(it) } } + + /** + * #1635: the unbonded DIS read chain is mid-flight. + * + * DIS reads and CCCD writes share ONE serialized GATT queue, and a field capture showed why that + * matters: the probe fired while the chain was still running, every descriptor write came back + * `writeDescriptor busy`, all four gave up after the shared 8-retry budget, and the link produced no + * answer at all. The probe had been scheduled on a fixed delay chosen on the reasoning that three + * seconds was enough separation — on that link the chain was still going at seven. + */ + private var disChainInFlight = false + + /** #1635: has the "stopped asking Android to pair" line been printed this process? Same one-shot + * latch idiom as [helloOverrideExhaustedLogged] — the reason does not change between links, and the + * give-up it reports is persisted, so repeating it every connect would be noise. */ + private var explicitBondGiveUpLogged = false + + /** #1635: how many times the probe has stood aside for the DIS chain on this link. */ + private var unbondedProbeDeferrals = 0 + + /** #1635: links that subscribed the puffin chars and drew no reply — the silence budget for the + * connected strap, read through prefs on every use. + * + * Was a field, and the field was the bug: the foreground service restarts, the count went back to + * zero, and the probe bought three more link-killing attempts on a strap that had already answered + * the same way three times (18 starts across 24 connects, 31 Aug). Reading it through prefs is a + * handful of cheap lookups per link and leaves exactly one source of truth. + * + * Held by [PuffinExperiment] rather than `NoopPrefs`, unlike the refusal latch beside it: the switch's + * setter clears these budgets by prefix and can only sweep its own prefs file. */ + private val unbondedProbeSilentLinks: Int + get() = runCatching { + PuffinExperiment.from(context).unbondedProbeSilentLinks(lastDeviceAddress) + }.getOrDefault(0) + + private fun setUnbondedProbeSilentLinks(value: Int) { + runCatching { PuffinExperiment.from(context).setUnbondedProbeSilentLinks(lastDeviceAddress, value) } + } + + /** #1804: consecutive local-teardown (status=22) probe links, persisted per device. A local + * teardown is inconclusive about the strap, so it charges THIS budget (with its own larger cap) + * rather than the silence budget. Bounded so a strap whose every link is torn down locally does + * not retry forever. */ + private val unbondedProbeInconclusiveLinks: Int + get() = runCatching { + PuffinExperiment.from(context).unbondedProbeInconclusiveLinks(lastDeviceAddress) + }.getOrDefault(0) + + private fun setUnbondedProbeInconclusiveLinks(value: Int) { + runCatching { PuffinExperiment.from(context).setUnbondedProbeInconclusiveLinks(lastDeviceAddress, value) } + } + + /** #1804: charge the inconclusive budget for a local teardown. A genuine answer clears it + * (alongside the silence budget), because whatever the quiet links were, they were not this + * strap declining to talk. */ + private fun chargeUnbondedProbeInconclusive() { + val spent = unbondedProbeInconclusiveLinks + 1 + setUnbondedProbeInconclusiveLinks(spent) + log("Unbonded offload probe: inconclusive link budget now $spent/$UNBONDED_PROBE_MAX_INCONCLUSIVE_LINKS" + + " (local teardowns are not strap verdicts, but the probe must still terminate (#1804)).") + } + /** True when the user asked to disconnect; suppresses the auto-rescan (Swift `intentionalDisconnect`). * Written on the main looper (connect/disconnect/keep-alive bounce) and read on the GATT binder * thread (handleDisconnect), so it must be @Volatile for cross-thread visibility. */ @@ -2194,6 +2711,9 @@ class WhoopBleClient( /// The strap family the user chose to pair, remembered so an auto-reconnect after a /// dropout re-scans for the same model instead of falling back to WHOOP 4.0. private var selectedModel = WhoopModel.WHOOP4 + /** #1635: one [ScanAdvertisementSummary] line per scan session, not one per discovery burst. */ + private var advertisementLogged = false + /** #716: true once the seeded "WHOOP" model has been stamped to the correct family. */ private var modelStamped = false /// The last device we connected to, kept so an auto-reconnect after a dropout can connect @@ -2228,6 +2748,56 @@ class WhoopBleClient( * can be reported with an elapsed time. 0 means none is outstanding. Cleared in [reset]. */ @Volatile private var clientHelloWriteAtMs: Long = 0L + /** Was a CLIENT_HELLO actually WRITTEN on this link? Distinct from [clientHelloWriteAtMs], which is + * cleared the moment the write completes: this stays true for the rest of the connection so a later + * backfill deferral can say whether the handshake was ever reachable. Cleared in [reset]. */ + @Volatile private var helloWrittenThisLink = false + + /** Key for the persisted deferral run. In SharedPreferences rather than memory because the run is the + * whole point: one deferral is the experiment working, a long run is the permanent SMP-0x05 state, + * and a process restart used to reset the count to 1 and print "expected on the connect that asks" + * over a strap that had never once completed a handshake. A field log is usually taken well after + * the restart that hid it. */ + private val helloDeferredKey = "ble.helloDeferredConsecutive" + + /** The run so far. Guarded: a prefs failure must never break a connect, and 0 reads as "no run", which + * is the safe direction - it under-states rather than inventing a history. */ + private fun helloDeferredRun(): Int = + runCatching { NoopPrefs.of(context).getInt(helloDeferredKey, 0) }.getOrDefault(0) + + private fun setHelloDeferredRun(value: Int) { + runCatching { NoopPrefs.of(context).edit().putInt(helloDeferredKey, value).apply() } + } + + /** Whether the deferral's full guidance paragraph has been printed for the CURRENT run. The deferral + * fires once per connect on a path documented to loop (57 cycles in an hour, see [HelloSuppression]), + * so the paragraph is one-shot per run and later connects log a terse countable line instead. Same + * latch idiom as [helloOverrideExhaustedLogged]. Deliberately NOT persisted alongside the run: a new + * process should state the guidance once more, because whoever restarted the app is the reader most + * likely to need it. */ + @Volatile private var helloDeferredGuidanceLogged = false + + /** How many times [beginBackfill] has declined on this link, so repeats carry a count instead of + * reading as new information each time. Cleared in [reset]. */ + @Volatile private var backfillDeferralsThisLink = 0 + + /** Consecutive live-persist failures per transport, and when each last reported. The live cadence is + * seconds, so the lines are rate-limited; the COUNT is what separates a transient the re-buffer + * absorbs from a store that is never going to accept these rows. + * + * Kept PER TRANSPORT because the standard 0x2A37 path and the puffin REALTIME_DATA path (#1118) + * fail independently — a shared counter would let one path's success reset the other's run and + * report a persistent failure as a string of first-failures. + * + * AtomicInteger, unlike the census fields above which are deliberately unsynchronized: those + * tolerate a stale read (one duplicate line), but `+=` on a plain Int can LOSE an increment, and + * the count here is the entire load-bearing distinction between a transient and a run. The two + * flushes can run concurrently on the io scope, so that race is reachable. */ + private val liveInsertFailuresStd = java.util.concurrent.atomic.AtomicInteger(0) + private val liveInsertFailuresRealtime = java.util.concurrent.atomic.AtomicInteger(0) + @Volatile private var lastStdInsertFailureLogMs = 0L + @Volatile private var lastRealtimeInsertFailureLogMs = 0L + /** #1635: ms since the CLIENT_HELLO write, or null when none is outstanding. Lets the bond-state * observer time a pairing transition against the write that may have triggered it, and report no * elapsed time at all for a transition belonging to some other pairing. */ @@ -2429,6 +2999,35 @@ class WhoopBleClient( */ private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + /** + * Seed the last-sync display from the ACTIVE strap's own stamp — PR #556's intent, correctly attributed. + * + * Resolved against the registry's active row, exactly as the strap-log header resolves firmware and + * last-sync. NOT against `NoopPrefs.lastDevice`, which records the last strap to BOND: on a two-strap + * install that is whichever was worn most recently, not the one the screens are scoped to, so seeding + * from it would put one strap's sync time on the other's Today screen. + * + * The legacy global still applies when exactly one strap is paired, because only then can it not be + * ambiguous (see [resolveLastSync]). On the install that produced the capture — two straps, the active + * one having never synced — this correctly leaves the display empty and the 5/MG reads "never". + */ + private fun seedLastSyncFromActiveStrap() { + val registry = (context.applicationContext as? com.noop.NoopApplication)?.deviceRegistry ?: return + ioScope.launch { + val rows = runCatching { registry.all() }.getOrDefault(emptyList()) + val activeId = runCatching { registry.activeDeviceId() }.getOrNull() + val activeAddr = rows.firstOrNull { it.id == activeId }?.peripheralId + val seed = resolveLastSync( + perDevice = runCatching { NoopPrefs.lastSyncAtFor(context, activeAddr) }.getOrDefault(0L), + legacyGlobal = runCatching { NoopPrefs.lastSyncAt(context) }.getOrDefault(0L), + pairedCount = rows.size, + ) ?: return@launch + // Never overwrite a value this session earned: a HISTORY_COMPLETE landing while the registry + // read is in flight is newer than anything persisted, and must win. + _state.update { st -> if (st.lastSyncAt == null) st.copy(lastSyncAt = seed) else st } + } + } + /** * Durable archive for undecodable history record frames (#77/#91). Written BEFORE the strap is * acked, so an unrecognised firmware layout can't cost the user their only copy: the ack frees @@ -2437,6 +3036,7 @@ class WhoopBleClient( private val rawHistoryArchive = RawHistoryArchive(context) init { + seedLastSyncFromActiveStrap() // Retro-decode (#151): when the decoder gains a historical layout (WHOOP 4.0 v25), re-run every // archived undecodable frame through it and insert whatever now decodes — the only path by // which already-acked, strap-freed history backfills after an update. Runs once per APP version @@ -2459,6 +3059,7 @@ class WhoopBleClient( deviceId = deviceId, cursorStore = cursorStore, ackTrim = { trim, endData -> ackHistoricalChunk(trim, endData) }, + onBankedOffload = { counts -> addBankedOffload(counts) }, onChunkCommitted = { batch -> onBackfillChunkCommitted(batch) }, onConsoleChunk = { consoleChunksThisSession += 1 }, // #77/#91: archive undecodable frames before the ack. append() returns ok=true (written, or @@ -2497,7 +3098,20 @@ class WhoopBleClient( @Suppress("UNUSED_PARAMETER") private fun onBackfillChunkCommitted(batch: StreamBatch) { decodedChunksThisSession += 1 // invoked once per non-empty decoded chunk (#77 family tally) - if (!analyzeAfterBackfillScheduled.compareAndSet(false, true)) return + schedulePostBackfillAnalysis() + } + + private fun schedulePostBackfillAnalysis() { + if (!analyzeAfterBackfillScheduled.compareAndSet(false, true)) { + // A later chunk arrived while the debounce or scoring pass was already active. Remember it: + // sleep-critical gravity commonly trails HR, and dropping this signal is how a partial first + // pass could become the final answer until the 15-minute backstop. + analyzeAfterBackfillPending.set(true) + return + } + // The debounce is part of the calculation lifecycle. Publish this before waiting so Sleep never + // flashes a final "not detected" verdict between HISTORY_COMPLETE and the scoring pass starting. + _state.update { it.copy(analyzingHistory = true) } ioScope.launch { try { delay(POST_BACKFILL_ANALYZE_DELAY_MS) // let trailing chunks of the same session land @@ -2508,17 +3122,17 @@ class WhoopBleClient( val profile = profileStore.toUserProfile() // #836: the post-backfill pass is a real update path, so it ALWAYS re-scores (mirroring the // Swift `analyzeRecent(force: true)` call `refreshAfterCompletedBackfill` makes) — but it must - // ADVANCE the shared HR-fingerprint watermark on success, which it previously did NOT. That + // ADVANCE the shared raw-analysis watermark on success, which it previously did NOT. That // watermark logic lived only in AppViewModel's 15-min loop, so after this pass the very next // idle tick saw `fp != watermark` and re-ran the IDENTICAL maxDays×~54h re-score — the // double-charge that made every reconnect pay for the multi-day pass twice. Swift already // advances the watermark at the end of EVERY successful analyzeRecent (IntelligenceEngine.swift); // this brings Android into lockstep. Captured before the run, written only on success, so an // interrupted/failed pass can never advance the watermark past unscored data. - val analyzeFp = repository.hrFingerprint() + val analyzeFp = repository.analysisFingerprint() // Attribute this forced post-offload re-score. A completed offload ALWAYS re-scores (#836), // so an EMPTY/duplicate offload (rows=0, common on a flapping link) still pays for a full - // ~18-day pass over the whole raw store (#1146). Compare the pre-run HR fingerprint + // ~18-day pass over the whole raw store (#1146). Compare the pre-run raw-input fingerprint // (rowCount:maxTs) to the watermark the last successful run advanced: `newData=no` means // nothing changed since the last run — a re-score driven purely by the reconnect+offload, not // by data. These lines quantify the background battery cost (#1005). Log-only; behaviour is @@ -2529,13 +3143,18 @@ class WhoopBleClient( // flapping-link offload storm (~186 passes in 7.5h were measured) that churn made the // reactive Trends/streak Flows flicker between full and empty — a scare that looked like // data loss (#1196). Scoped to THIS post-offload trigger only: import/edit/settings/ - // recalibrate re-scores force regardless of the HR fingerprint and are untouched. Twin of + // recalibrate re-scores force regardless of the raw-input fingerprint and are untouched. Twin of // the Swift `analyzeRecent(skipIfUnchanged:)` gate at the refreshAfterCompletedBackfill site. val newData = analyzeFp != NoopPrefs.analyzeWatermark(context) log("re-score: trigger=post-offload newData=" + if (newData) "yes" else "no (empty/duplicate offload — nothing changed since last run) — skipping (#1146)") if (newData) runCatching { + // #1816: set the motion sink so a fresh strap's first backfill flips the Today caption + // from "No motion synced yet" to the phone-step-days countdown as soon as it lands. + IntelligenceEngine.stepsHasMotionSink = { hasMotion -> + profileStore.stepsHasBankedMotion = hasMotion + } IntelligenceEngine.analyzeRecent( repo = repository, profile = profile, @@ -2620,10 +3239,18 @@ class WhoopBleClient( // persists the nightly @82 mean as "spo2_candidate" in metricSeries. spo2CandidateDisplay = NoopPrefs.spo2CandidateDisplay(context), effortMethod = NoopPrefs.effortMethod(context), + dayCycleMode = NoopPrefs.dayCycleMode(context), ) }.onSuccess { // Advance the shared watermark so the next 15-min tick sees no change and skips (#836). NoopPrefs.setAnalyzeWatermark(context, analyzeFp) + // #1735: stamp the post-sync pass too, not just the idle one in AppViewModel. This is + // the pass that runs right after an offload, so it is the one a "synced but nothing + // appeared" report is actually asking about. + runCatching { + NoopPrefs.of(context).edit() + .putLong("score.lastPassAt", System.currentTimeMillis() / 1000).apply() + } log("Backfill: post-sync scoring pass done") // #277 diagnostic: surface the day-key the dashboard treats as "today" against the // newest banked row, so a UTC-bucket vs local-day split (rows persist but Today @@ -2642,6 +3269,8 @@ class WhoopBleClient( if (it is kotlin.coroutines.cancellation.CancellationException) throw it log("Backfill: post-sync scoring failed: ${it.message}") } + // #1816: clear the motion sink after the post-backfill pass completes. + IntelligenceEngine.stepsHasMotionSink = null // Keep the opt-in Health Connect writeback fresh in background-only operation too. if (NoopPrefs.hcWriteback(context)) { // #660: log the count AND any PII-safe failure categories (the writer also persists @@ -2650,7 +3279,17 @@ class WhoopBleClient( .onSuccess { r -> log("HC writeback: ${r.written} record(s)" + if (r.ok) "" else " (failed: ${r.failures.joinToString()})") } } } finally { + val retryAlreadyQueued = analyzeAfterBackfillPending.getAndSet(false) + if (!retryAlreadyQueued) _state.update { it.copy(analyzingHistory = false) } analyzeAfterBackfillScheduled.set(false) + // If anything landed after this pass was scheduled, run once more after the same quiet + // grace. The complete analysis fingerprint makes a duplicate retry cheap, while a trailing + // gravity/RR/sleep-state chunk now gets the decisive sleep-detection pass immediately. + // Check pending again after releasing the scheduled latch so a chunk racing this finally + // block cannot strand its retry signal. + if (retryAlreadyQueued || analyzeAfterBackfillPending.getAndSet(false)) { + schedulePostBackfillAnalysis() + } } } } @@ -2769,6 +3408,7 @@ class WhoopBleClient( private var whoop5HistoryAttempts = 0 /** One-shot debounce: a post-backfill scoring pass is already scheduled/running. */ private val analyzeAfterBackfillScheduled = java.util.concurrent.atomic.AtomicBoolean(false) + private val analyzeAfterBackfillPending = java.util.concurrent.atomic.AtomicBoolean(false) /** Guards the once-per-connect initial offload kick (Swift `backfillStarted`). */ private var backfillStarted = false @@ -2776,6 +3416,54 @@ class WhoopBleClient( // #520 DIS identity — read ONCE per connection, post-handshake, 5/MG only. Serial and hardware // revision are immutable, so they are never re-polled (unlike the battery). Reset on disconnect. private var disRead = false + /** + * #1303: the strap's DIS serial, once read (5.0/MG only — a 4.0 does not expose one). + * + * KNOWN ASYMMETRY with the Swift twin, which adopts inline in BLEManager and uses its closure only + * to notify. Here the adoption itself lives in the observer (AppViewModel owns the registry handle + * and a scope; SourceCoordinator is inert on the WHOOP path by design), so a connect that completes + * before any observer is wired emits into a null callback and does not adopt. It is deferred, not + * lost: DIS is read on every connect, so the next one with an observer alive adopts. Worth knowing + * when reading a capture where iOS shows the adoption line and Android does not. + */ + var onSerial: ((String) -> Unit)? = null + /** + * #1193: the WHOOP 4.0 strap serial from `GET_HELLO_HARVARD`, once it has been seen TWICE. + * + * The 5/MG adopts its DIS serial on first read, because `0x2A25` is a spec-defined field that means + * one thing. The 4.0 offset is not that: it was read off a single capture, so "the 9-char alnum run at + * offset 14" is a strong inference rather than a documented field. If that run turned out to be + * per-session rather than per-strap, adopting it immediately would mint a NEW id on every connect and + * migrate the history each time — strictly worse than the duplicate row this exists to prevent. + * + * So a value must repeat before it is trusted. Two hellos carrying the same run cannot both be a fresh + * per-session token, which is the only failure mode that would do real damage. The cost is that a 4.0 + * adopts one connect later than a 5/MG; the benefit is that the destructive failure cannot happen at + * all. Deliberately NOT cleared on disconnect — the two sightings are meant to span connects. + * + * The withholding rule itself lives in [com.noop.protocol.RepeatedSerialGate], beside the decoder, + * where it is unit tested; the gate holds the confirmed value, so nothing here needs to store it. The + * Swift twin DOES keep a field, because its adoption reads a property where this side passes the + * value into [onSerial] directly - the shapes differ, the behaviour does not. + */ + private val harvardSerialGate = com.noop.protocol.RepeatedSerialGate() + + /** + * A 4.0 hello serial arrived. Adopt only on the SECOND sighting of the same value, then hand it to the + * SAME [onSerial] the 5/MG DIS read uses, so both families share one adoption path (#1193). + */ + private fun noteHarvardSerial(serial: String) { + // Do NOT advance the gate without a listener. The gate confirms EXACTLY ONCE, so a confirmation + // delivered to a null [onSerial] is not deferred, it is lost for the lifetime of the process — + // every later sighting returns null because the value is already confirmed. And "no listener" is + // an ordinary state here, not a defensive hypothetical: [onSerial] is wired by AppViewModel, which + // is UI-scoped, while this client also runs under WhoopConnectionService with no UI alive. The + // 5/MG path survives the same hazard only because it re-offers its DIS serial on every connect. + val cb = onSerial ?: return + val confirmed = harvardSerialGate.offer(serial) ?: return + cb.invoke(confirmed) + } + private var disSerial: String? = null private var disHwRev: String? = null /** DIS 0x2A24, the strap's own model number — the authoritative variant signal (#520). */ @@ -2829,9 +3517,10 @@ class WhoopBleClient( private val liveBuffer = ArrayList>() private var batchStartedAtMs = System.currentTimeMillis() - /** Standard 0x2A37 HR/RR buffer — the reliable, always-on stream (port of Collector.stdHR/stdRR). */ + /** Standard 0x2A37 HR/RR/contact buffer — the reliable, always-on stream. */ private val stdHr = ArrayList() private val stdRr = ArrayList() + private val stdContact = ArrayList() // --- Offload frame drain (preserves START/data/END arrival order; port of routeBackfillFrame) --- @@ -2873,6 +3562,21 @@ class WhoopBleClient( @Volatile private var realtimeArmed = false /** Wall-clock of the last inbound notification — drives the keep-alive liveness watchdog. */ @Volatile private var lastDataAtMs = 0L + + /** + * #1865: when a LIVE HR sample last arrived — distinct from [lastDataAtMs], which any inbound frame + * refreshes. + * + * That distinction is the whole bug. A 5/MG whose realtime stream has lapsed still answers battery + * polls and still serves history, so [lastDataAtMs] keeps the link looking perfectly healthy and the + * stall bounce never fires. Only an HR-specific clock can see "the stream is dead while the link is + * fine", which is exactly the state reported: bonded, worn, history synced just now, no bpm. + */ + @Volatile private var lastLiveHrAtMs = 0L + + /** #1865: one re-arm per stall episode, cleared when live HR resumes. Same shape as + * [resubscribedSinceData] — recovering costs one write, repeating it every tick would not. */ + @Volatile private var realtimeReArmedSinceHr = false /** True once we've re-subscribed during the CURRENT quiet episode, so the keep-alive re-subscribes * at most once between data arrivals instead of flooding descriptor writes every 30s tick (#77). * Reset to false in [onInbound] when fresh data lands. */ @@ -2955,15 +3659,42 @@ class WhoopBleClient( // ==================================================================================== /** - * Begin scanning for the WHOOP custom service, then connect to the first match. + * USER-initiated connect (the Connect button, the scan flow, Add-a-WHOOP). Begin scanning for the + * WHOOP custom service, then connect to the first match. * Port of `BLEManager.connect()` → `central.scanForPeripherals(withServices:[customService])`. + * + * The ONLY entry that grants the #1635 one-shot handshake retry. System-initiated paths — the + * radio-on rescan, the bond-loop salvage probe, both deferred reconnect timers — MUST use + * [connectFromSystem]. Field log 260901-1022: the stale-OS-bond fallback routed a SCAN-driven + * reconnect through here, so a suppressed strap wrote a second unanswered hello and paid a second + * ~4.8s drop that no user had asked for. Apple has kept these two entries apart since #78 hole-2. */ @SuppressLint("MissingPermission") - fun connect(model: WhoopModel = WhoopModel.WHOOP4) { + fun connect(model: WhoopModel = WhoopModel.WHOOP4) = connectInternal(model, userInitiated = true) + + /** + * SYSTEM-initiated connect: identical to [connect] except it never grants the handshake retry. + * + * A timer firing is not the user saying "try again". Suppression exists precisely so the automatic + * reconnects behind a failed attempt stop writing the hello; handing them the retry re-opens the loop + * one link at a time, and does it invisibly, because every line it produces looks exactly like the + * user having tapped Connect. Kotlin twin of `BLEManager.connectFromSystem`. + */ + @SuppressLint("MissingPermission") + fun connectFromSystem(model: WhoopModel = WhoopModel.WHOOP4) = connectInternal(model, userInitiated = false) + + @SuppressLint("MissingPermission") + private fun connectInternal(model: WhoopModel, userInitiated: Boolean) { + // #1881: only the SYSTEM path is gated. This class already draws that line — `connect()` is the + // user's explicit Connect button, `connectFromSystem()` is every automatic path — and the report's + // complaint is only ever about NOOP acting on its own. Gating both would have made the Connect + // button silently dead while DevicesScreen showed a "Reconnecting…" toast. + if (!userInitiated && !whoopConnectAllowed("connect-from-system")) return intentionalDisconnect = false // #1635: an explicit Connect is the user saying "try the handshake again" — the documented way out - // of hello suppression, and the reason suppression is never a permanent verdict. - helloRetryRequested = true + // of hello suppression, and the reason suppression is never a permanent verdict. Consumed by the + // first connect that reaches the hello decision, so it is granted here and nowhere else. + if (userInitiated) helloRetryRequested = true // PR #588: an explicit user-driven Connect is never an out-of-range retry — clear the involuntary- // reconnect streak so this scan (and any reconnects it spawns) starts back at the snappy // LOW_LATENCY scan mode + the 3s backoff base, never inheriting a backed-off lower-power scan. @@ -3006,13 +3737,46 @@ class WhoopBleClient( // pipeline consistent. A stale connection or bond falls back to a scan via handleDisconnect. val direct = getConnectedWhoopDevice() ?: bondedWhoopDevice() if (direct != null) { + // #1635: we may already HOLD this link. getConnectedWhoopDevice() answers "the OS has this + // device connected", which is true of our own live GATT too — so without this, a Connect tap + // tore down a working link and rebuilt it. Field log 260901-0121: gen=6 had been up 18m 32s + // streaming HR when a tap replaced it with gen=7. + // + // Only when a reconnect could achieve NOTHING, which is exactly the genuinely-bonded case: + // the handshake is done, so there is no retry to spend and no state a fresh link would fix. + // A suppressed strap is deliberately NOT covered — there the tap IS the handshake retry, and + // the hello can only be written on a new link's discovery, so it must still reconnect. + // + // AFTER the selectedModel pin, not before: both helpers match only a 5/MG, so this branch is + // also where a stale selection gets corrected. Returning above it would leave the picker and + // any later rescan on the wrong family. selectedModel = WhoopModel.WHOOP5_MG + // `connected` alone is not evidence the link WORKS — a silently dead GATT reports connected + // until the watchdog bounces it, and that is precisely when someone taps Connect. Declining + // to act there would turn the button inert in the one state it exists for, so require the + // link to be live by the watchdog's own measure. + val silentMs = System.currentTimeMillis() - lastDataAtMs + if (connectKeepsExistingLink( + genuinelyBonded = didBond, + connected = _state.value.connected, + sameDevice = gatt?.device?.address == direct.address, + silentMs = silentMs, + stallFuseMs = liveLinkStallFuseMs(), + ) + ) { + // Consume it. The retry belongs to the tap that asked, and leaving it set would hand a + // stale one to a later AUTOMATIC reconnect - the same leak the deferral branch warns of. + helloRetryRequested = false + log("Connect: already connected and bonded to ${direct.name ?: "WHOOP"}, last data" + + " ${silentMs / 1000}s ago — keeping the link rather than rebuilding it (#1635).") + return + } log("Easy-connect: attaching directly to ${direct.name ?: "WHOOP"} (no scan needed)") _state.update { it.copy( scanning = false, whoop5Detected = false, statusNote = "Connecting to your ${WhoopModel.WHOOP5_MG.displayName}…", ) } - connectToDevice(direct) + connectToDevice(direct, alreadyAuthorised = userInitiated) bondedDirectAttempt = true // after connectToDevice: reset() must not clear it return } @@ -3027,7 +3791,16 @@ class WhoopBleClient( * the fallback and the not-found timeout. Port of macOS BLEManager.startScan(for:allowFallback:). */ @SuppressLint("MissingPermission") + /** + * No gate here, deliberately: the only callers are [connectInternal] — which gates the system path — + * and this method's own family-rotation timeout, which cannot start a scan that one of those did not. + * Gating here as well would block the user's explicit Connect. + */ private fun startScan(model: WhoopModel, allowFallback: Boolean) { + // #1635: one advertisement line per SCAN, not per process. The question this answers is whether + // a strap advertises differently in pairing mode, which is only visible by comparing a scan + // before it was put into pairing mode against one after — so the latch has to reopen here. + advertisementLogged = false handler.removeCallbacks(scanFallbackRunnable) // Defensive: the normal auto-connect scan is NEVER a present-scan. Clearing the flag here means a // leaked wizard present-scan (e.g. the wizard was dismissed without stopWhoopScan) can't divert @@ -3171,7 +3944,7 @@ class WhoopBleClient( connectToDevice(dev, autoConnect = true) } else { log("Bluetooth radio back on — rescanning for your ${selectedModel.displayName}") - connect(selectedModel) + connectFromSystem(selectedModel) } } } @@ -3198,7 +3971,7 @@ class WhoopBleClient( intentionalDisconnect = false val dev = lastDevice if (dev != null && isPreferred(dev)) connectToDevice(dev, autoConnect = true) - else connect(selectedModel) + else connectFromSystem(selectedModel) } } @@ -3281,6 +4054,8 @@ class WhoopBleClient( // changed in between, and that is exactly when a new characteristic would appear. gattTreeDumpedFor = null handler.post { + // Captured before the clearing below nulls `lastDevice`, which `lastDeviceAddress` reads through. + val releasedAddress = lastDeviceAddress intentionalDisconnect = true // defuse the disconnect→3s-reconnect loop's guard handler.removeCallbacks(scanTimeoutRunnable) handler.removeCallbacks(scanFallbackRunnable) @@ -3293,6 +4068,16 @@ class WhoopBleClient( // so a paused state can never outlive the strap it belonged to and wedge a later re-add. bondRefusalStreak = 0 bondGiveUp.reset() + // #1635: the hello-suppression latch is exactly that kind of state and, unlike the give-up, it + // is PERSISTED — so without this it outlives the strap it belonged to, silently suppressing the + // handshake on a re-add and leaving a stale pref behind for a strap the user removed. + // Re-latching costs the same five refusals it always did. Twin of the Swift `forgetDevice`. + runCatching { com.noop.ui.NoopPrefs.setHelloSuppressed(context, releasedAddress, false) } + // #1635: clearing the latch means the pairing request is asked again, so its one-shot line has + // to be able to report the next retirement. Twin of the reset in [clearPairingHint]; without + // it a re-added strap stops asking with nothing in the log saying why, which is the silent + // stop this whole area exists to stop repeating. + explicitBondGiveUpLogged = false autoReconnectPausedForBondLoop = false bondLoopPausedAtMs = null // Drop the persisted last-device pin so a relaunch / radio-on doesn't auto-reconnect to it (#67). @@ -3328,6 +4113,99 @@ class WhoopBleClient( backfiller.deviceId = id } + /** + * Whether a WHOOP is the device the user actually selected (#1881). FAIL-OPEN: `true` unless + * something positively says otherwise, so the single-WHOOP path and every unknown state behave + * exactly as before — a wrong `false` here would stop the strap connecting for everyone. + * + * The DURABLE form of the coordinator's [SourceCoordinator] `stopWhoop`. That call is edge-triggered, + * fired once on the WHOOP -> other-source transition, while the WHOOP flow has system-driven entry + * points that consult nobody — [onBluetoothRadioOn] above all, which every radio toggle reaches and + * which explicitly clears `intentionalDisconnect` before reconnecting. Swift twin: `BLEManager`. + */ + @Volatile + private var whoopIsActiveDevice = true + + /** The last entry point [whoopConnectAllowed] turned away, so a rotation timer cannot flood the log. */ + private var lastBlockedConnectReason: String? = null + + /** + * Record whether a WHOOP is the active device (#1881). Called from the SAME closures the + * [SourceCoordinator] already uses to stop and start the WHOOP, so it inherits their semantics — + * including any deliberate exception that calls neither. + */ + fun setWhoopIsActiveDevice(active: Boolean) { + if (whoopIsActiveDevice == active) return + whoopIsActiveDevice = active + log( + if (active) "WHOOP is the active device again — scanning and reconnecting are allowed" + else "WHOOP is no longer the active device — not scanning or connecting until it is again (#1881)" + ) + } + + /** The one gate every WHOOP scan/connect entry point passes through (#1881). Swift twin: `BLEManager`. */ + private fun whoopConnectAllowed(reason: String): Boolean { + if (whoopIsActiveDevice) return true + // The flag is a CACHE of a registry fact, and the registry is the authority. Re-validate so the + // gate can never latch: it is set from the coordinator's stop/start closures and seeded at + // startup, and if those ever disagree a stale `false` would stop the strap connecting for the + // whole session. + // + // Asynchronously, unlike the Swift twin's inline read: the Room registry is `suspend` and this + // runs on the BLE handler thread, where `runBlocking` would be a worse bug than the one it fixes. + // So the re-validation lands for the NEXT attempt rather than this one — which is enough, because + // every caller here retries (the family rotation, the reconnect backoff, the radio-on re-arm). + revalidateWhoopIsActive() + if (lastBlockedConnectReason != reason) { + lastBlockedConnectReason = reason + log("Not connecting the WHOOP ($reason): a different device is active (#1881)") + } + return false + } + + /** Re-read whether a WHOOP is active and un-block the gate if it is. See [whoopConnectAllowed]. */ + private fun revalidateWhoopIsActive() { + val registry = (context.applicationContext as? com.noop.NoopApplication)?.deviceRegistry ?: return + ioScope.launch { + val activeId = runCatching { registry.activeDeviceId() }.getOrNull() ?: return@launch + val rows = runCatching { registry.all() }.getOrNull() ?: return@launch + val row = rows.firstOrNull { it.id == activeId } + if (row == null || SourceIdentity.isWhoop(row)) setWhoopIsActiveDevice(true) + } + } + + /** + * Attribute this connection to the strap that ACTUALLY connected, not to whatever device is active + * (#1881). Resolves the registry row whose `peripheralId` matches [address] and re-points [deviceId] + * and the in-flight [Backfiller] to it. + * + * Async because the Room registry read is `suspend` (the Swift twin is synchronous GRDB and does this + * inline in `didConnect`). The window is between STATE_CONNECTED and the first persisted sample — + * service discovery, notification subscription and the strap's first response all sit inside it — so + * a row is not expected to land first; if one ever did it would carry the previous id. + * + * Conservative by construction: only ever moves the id to a row that is BOTH a WHOOP and matched by + * address. No registry, no match (the legacy single-WHOOP row before it has adopted an address), a + * non-WHOOP match, or an id already correct all leave it alone. + * - the matched row is already the current id -> no write. + * + * Known narrow residual: the disconnect path flushes buffered live rows best-effort, and the persist + * sites read [deviceId] at PERSIST time — so a flush still in flight when the next link re-points the + * id would attribute the previous link's tail to the new strap. It needs a re-point to happen at all, + * which only occurs when the resolved row differs from the current id: never on a settled + * single-WHOOP install. Swift twin carries the same note. + */ + private fun adoptSourceIdentity(address: String?) { + val addr = address ?: return + val registry = (context.applicationContext as? com.noop.NoopApplication)?.deviceRegistry ?: return + ioScope.launch { + val rows = runCatching { registry.all() }.getOrNull() ?: return@launch + val resolved = SourceIdentity.resolve(addr, rows, deviceId) ?: return@launch + log("Attributing this link to $resolved — the strap that connected, not the active device (#1881)") + setActiveDeviceId(resolved) + } + } + /** * Add-a-device wizard present-scan (MW-4): scan the given WHOOP family's service and surface every * nearby strap in [discoveredWhoops] WITHOUT auto-connecting. Turns on [scanningForList] so @@ -3448,7 +4326,13 @@ class WhoopBleClient( // 5/MG allow-list: live HR, buzz, and the historical-offload pair (trigger + ack). The // offload commands ride the SAME proven puffin COMMAND frame as the Swift path // (whoop5HistoricalAckFrame = puffinCommandFrame(23, [0x01]+endData)). (#78) - if (cmd != CommandNumber.TOGGLE_REALTIME_HR && cmd != CommandNumber.RUN_HAPTICS_PATTERN && + if (cmd != CommandNumber.TOGGLE_REALTIME_HR && + !(cmd in setOf( + CommandNumber.START_RAW_DATA, + CommandNumber.STOP_RAW_DATA, + CommandNumber.TOGGLE_IMU_MODE, + ) && groundTruthImuCommandAllowed) && + cmd != CommandNumber.RUN_HAPTICS_PATTERN && cmd != CommandNumber.SEND_HISTORICAL_DATA && cmd != CommandNumber.HISTORICAL_DATA_RESULT && // ABORT_HISTORICAL_TRANSMITS (20) over puffin: stop an offload already in flight. Allowed // ONLY while one actually is, so a default install can never form these bytes on a 5/MG — @@ -3470,6 +4354,14 @@ class WhoopBleClient( // probeBodyLocationAndStatus() (user-initiated, Test Centre gated); response decoded to a // diagnostic report only, never gates wear/scoring. Whether 5/MG answers is a hardware check. cmd != CommandNumber.GET_BODY_LOCATION_AND_STATUS && + // GET_BATTERY_PACK_INFO (151) over puffin: read-only read of the pack's charge/serial. + // Gated HARDER than the 98/84 probes above — allowed ONLY while a probe is actually in + // flight, the 117/118 treatment — because unlike those two this opcode has never been + // sent to any strap by this app, so a default install must not be able to form these + // bytes at all. Driven only by probeBatteryPackInfo() (user-initiated, Test Centre + // gated). Whether a 5/MG answers at all is exactly the hardware question being asked. + !(cmd == CommandNumber.GET_BATTERY_PACK_INFO && + _batteryPackProbe.value == WAITING_BATTERY_PACK_PROBE) && // START_FF_KEY_EXCHANGE (117) / SEND_NEXT_FF (118) over puffin: the READ-ONLY feature-flag // ENUMERATION probe (#761) — it reads the strap's own flag NAMES and writes no value. Gated // harder than the probes above: allowed ONLY while a probe is actually in flight, so on a @@ -3676,7 +4568,7 @@ class WhoopBleClient( try { val nowSec = System.currentTimeMillis() / 1000L val from = nowSec - INACTIVITY_LOOKBACK_S - val grav = repository.gravitySamples(deviceId, from, nowSec) + val grav = repository.gravitySamplesForDevice(deviceId, from, nowSec) if (grav.isEmpty()) return@launch val decision = SedentaryDetector.evaluate( @@ -3727,7 +4619,7 @@ class WhoopBleClient( // gravity window, the same primitive SedentaryDetector reuses. Null when there's no // recent gravity — the engine then leans on the resting-HR band gate (spec Q3). val from = nowSec - INACTIVITY_LOOKBACK_S - val grav = runCatching { repository.gravitySamples(deviceId, from, nowSec) }.getOrDefault(emptyList()) + val grav = runCatching { repository.gravitySamplesForDevice(deviceId, from, nowSec) }.getOrDefault(emptyList()) val recentMotionG = WorkoutDetector.activitySeries(grav).lastOrNull()?.intensity val live = _state.value @@ -3781,9 +4673,9 @@ class WhoopBleClient( // Look back over the freshly-offloaded daytime window (the same lookback the inactivity / // stress hooks read), so a brief afternoon nap that just landed gets judged. val from = nowSec - INACTIVITY_LOOKBACK_S - val grav = runCatching { repository.gravitySamples(deviceId, from, nowSec) }.getOrDefault(emptyList()) + val grav = runCatching { repository.gravitySamplesForDevice(deviceId, from, nowSec) }.getOrDefault(emptyList()) if (grav.isEmpty()) return@launch - val hr = runCatching { repository.hrSamples(deviceId, from, nowSec) }.getOrDefault(emptyList()) + val hr = runCatching { repository.hrSamplesForDevice(deviceId, from, nowSec) }.getOrDefault(emptyList()) // Honest resting band: the newest daily metric's resting HR, or null (the engine then // leans on motion alone at lower confidence — it never fabricates a band). val restingHr = runCatching { @@ -3962,6 +4854,40 @@ class WhoopBleClient( /** Clear the #690 probe result (Devices dialog dismissed). */ fun clearBodyLocationProbe() { _bodyLocationProbe.value = null } + /** Read-only cmd-151 probe: ask the strap for its battery pack's charge/serial/address and let the + * COMMAND_RESPONSE hook decode + surface it. User-initiated (Test Centre gated). Decodes into the + * dialog and the strap log ONLY — it feeds no live state and gates nothing. + * + * KNOWN RESULT, so nobody re-runs this expecting data: on WHOOP MG fw 50.39.1.0 opcode 151 answers + * FAILURE(0) whether or not a pack is attached and charging, across five argument forms. The pack + * READOUT does not use this command at all — the strap volunteers the same record, unprompted, in + * an uncatalogued pushed event, 0x6D (109). This probe is kept only as RE + * instrumentation: the golden vectors behind `BatteryPackInfo` came from a WHOOP 5 rather than an + * MG, so a non-MG owner running this is exactly the capture that would show whether the FAILURE is + * MG-specific. Same read-only, Test-Centre-gated shape as the #592 and #690 probes. */ + fun probeBatteryPackInfo() { + if (!_state.value.connected) { + log("Battery-pack probe (151) ignored — not connected") + return + } + // MUST be set before send(): the 5/MG allow-list admits opcode 151 only while this sentinel is + // in place, so setting it afterwards would have the strap's own gate drop our own probe. + _batteryPackProbe.value = WAITING_BATTERY_PACK_PROBE + log("Battery-pack probe: sending GET_BATTERY_PACK_INFO(151, read-only) on family=$connectedFamily; the decoded reply is dumped below when it lands") + send(CommandNumber.GET_BATTERY_PACK_INFO) + // Silence is itself a verdict. ATOMIC compare-and-set so a real reply landing microseconds + // before the timeout is never clobbered by this late "no reply". + handler.postDelayed({ + val msg = "Battery-pack probe: no COMMAND_RESPONSE for opcode 151 within " + + "${BATTERY_PACK_PROBE_TIMEOUT_MS / 1000}s — the strap served no reply. On a WHOOP 4.0 that " + + "is EXPECTED (a 4.0 has no pack command; its pack reads as a VOLTAGE via opcode 98)." + if (_batteryPackProbe.compareAndSet(WAITING_BATTERY_PACK_PROBE, msg)) log(msg) + }, BATTERY_PACK_PROBE_TIMEOUT_MS) + } + + /** Clear the cmd-151 probe result (Devices dialog dismissed). */ + fun clearBatteryPackProbe() { _batteryPackProbe.value = null } + /** * #761 read-only probe: ask the strap to ENUMERATE the feature-flag key names its firmware knows — * `START_FF_KEY_EXCHANGE(117)` for the count, then `SEND_NEXT_FF(118)` repeatedly (its body is a @@ -4276,18 +5202,23 @@ class WhoopBleClient( * hardware-revision read is issued from [onInbound] once the serial lands. Firing both here would * silently drop the second. Read-only and non-fatal: any failure just leaves the variant UNKNOWN. */ - fun readDisIdentity() { - if (disRead) return - val g = gatt ?: return - if (connectedFamily == DeviceFamily.WHOOP4) return - val ops = gattOps ?: return + fun readDisIdentity(): Boolean { + // Returns whether a read was actually ISSUED, so a caller can tell "the chain is running" from + // "the chain never started". Every branch above the read is an exit that leaves nothing in + // flight, and the unbonded offload probe waits on this answer — told `true` unconditionally it + // would stand aside for a chain that does not exist and burn its whole budget for nothing. + if (disRead) return false + val g = gatt ?: return false + if (connectedFamily == DeviceFamily.WHOOP4) return false + val ops = gattOps ?: return false val ch = g.getService(DIS_SERVICE)?.getCharacteristic(DIS_SERIAL_CHAR) if (ch != null && (ch.properties and BluetoothGattCharacteristic.PROPERTY_READ) != 0) { disRead = true safeGatt("readCharacteristic(dis-serial)") { ops.readCharacteristicCompat(ch) } - } else { - log("DIS: serial characteristic unavailable — hardware variant stays unknown") + return true } + log("DIS: serial characteristic unavailable — hardware variant stays unknown") + return false } /** @@ -4303,6 +5234,11 @@ class WhoopBleClient( */ private fun noteReadFailure(uuid: java.util.UUID, status: Int) { if (uuid !in DIS_CHARS) return + // The chain is over. readNextDisExtra is only reached from the SUCCESS path, so a refused read + // ends it with nothing further in flight — and #490's whole subject is a strap that refuses. Left + // set, the unbonded offload probe would stand aside for its full budget waiting on a chain that + // had already died, in exactly the case the probe most wants a clear queue for. + disChainInFlight = false log(disReadFailureLine(uuid.toString(), gattWriteStatusLabel(status))) // Report it, but do NOT latch it when WE put a pairing in flight on this link. The latch is // persisted per device and permanent, and a read issued into a link that is mid-encryption @@ -4357,7 +5293,278 @@ class WhoopBleClient( ) return log("DIS: trying the identity read on an UNbonded link — unproven, and a refusal is itself the" + " answer to whether DIS needs an encrypted bond (#490)") - readDisIdentity() + disChainInFlight = readDisIdentity() + } + + /** + * Schedule the unbonded offload probe on a link that will carry NO CLIENT_HELLO (#1635). + * + * Called from BOTH no-hello paths for the same reason [scheduleUnbondedDisRead] is: the deferral path + * returns early, so scheduling it in the suppression path alone would skip exactly the people testing. + * + * Staggered AFTER the DIS chain rather than beside it. Both ride one serialized GATT queue, and the DIS + * reads are the cheaper, already-proven operation — letting four CCCD writes contend with them would + * make a failure in either unattributable to the one that caused it. + */ + private fun scheduleUnbondedOffloadProbe() { + handler.removeCallbacks(unbondedProbeStartRunnable) + handler.postDelayed(unbondedProbeStartRunnable, BATTERY_ON_CONNECT_DELAY_MS * 4) + } + + /** + * Stage 1: subscribe the puffin notify characteristics on an unbonded link. + * + * This is the operation nothing on record has ever completed. The offload arrives on fd4b0003/4/5/7, + * and those are subscribed in exactly one place — the post-CLIENT_HELLO branch — so a strap that never + * bonds never has them enabled and could not receive history even if it offered it. See + * [shouldProbeUnbondedOffload] for why the assumption that they need encryption has never been tested + * here. + */ + @SuppressLint("MissingPermission") + /** #1867: the latched per-device refusal, read the same way by the probe and by the handshake skip + * that serves it — two readers of one fact, so neither can drift from the other. */ + private fun unbondedOffloadPreviouslyRefused(address: String?): Boolean = runCatching { + unbondedOffloadRefusedPrefKey(address)?.let { + context.getSharedPreferences(com.noop.ui.NoopPrefs.NAME, android.content.Context.MODE_PRIVATE) + .getBoolean(it, false) + } ?: false + }.getOrDefault(false) + + private fun beginUnbondedOffloadProbe(g: BluetoothGatt) { + val refused = unbondedOffloadPreviouslyRefused(g.device.address) + // Read each input ONCE: the gate's verdict and the line that explains it must be about the same + // state, or the log will name a reason that was not the one that decided. + val isWhoop5Now = connectedFamily == DeviceFamily.WHOOP5 + val optedInNow = PuffinExperiment.from(context).unbondedOffload + val bondedNow = didBond + val helloWrittenNow = helloWrittenThisLink + val alreadyProbedNow = unbondedProbeStartedThisLink + val silentLinksNow = unbondedProbeSilentLinks + val inconclusiveLinksNow = unbondedProbeInconclusiveLinks + if (!shouldProbeUnbondedOffload( + isWhoop5 = isWhoop5Now, + optedIn = optedInNow, + bonded = bondedNow, + helloWrittenThisLink = helloWrittenNow, + alreadyProbedThisLink = alreadyProbedNow, + previouslyRefused = refused, + silentLinksSoFar = silentLinksNow, + inconclusiveLinksSoFar = inconclusiveLinksNow, + ) + ) { + // #1949: say WHY, once per link. UNGATED, unlike the pairing dump, and deliberately so: the + // domain argument to log() is a TAG rather than a gate, so this lands in an ordinary strap + // log. That is the point — it explains a silence to whoever reads the log they already have, + // and it costs one line, only on a no-hello 5/MG link, which is the only place the probe is + // scheduled. The dump is eight lines of readout and stays behind the Test Centre domain. + // + // A silent return here is what made an MG capture unreadable: + // the puffin chars discovered, never subscribed, and no way to tell the app declined from the + // strap refusing — opposite meanings for #1635. Once per link, because this path is retried. + if (!unbondedProbeSkipLogged) { + unbondedProbeSkipLogged = true + // The SAME values the gate just refused on, not a second read of each. `didBond` and the + // rest are @Volatile and the pref is a live file read, so re-reading them here could + // explain the skip with a state that is no longer the one that caused it. + unbondedProbeSkippedLine( + isWhoop5 = isWhoop5Now, + optedIn = optedInNow, + bonded = bondedNow, + helloWrittenThisLink = helloWrittenNow, + alreadyProbedThisLink = alreadyProbedNow, + previouslyRefused = refused, + silentLinksSoFar = silentLinksNow, + inconclusiveLinksSoFar = inconclusiveLinksNow, + )?.let { log(it, com.noop.testcentre.TestDomain.CONNECTION) } + } + return + } + // Stand aside while the DIS chain still holds the one serialized GATT queue. The fixed 6s delay + // this used to rely on was chosen by reasoning and was wrong: a capture caught the chain still + // running at 7s, every CCCD write returning busy, all four abandoned after the shared retry + // budget, and the link yielding no answer. Waiting on the actual signal costs a second and + // removes the guess. + if (unbondedProbeShouldWaitForDis(disChainInFlight, unbondedProbeDeferrals)) { + unbondedProbeDeferrals++ + if (unbondedProbeDeferrals == 1) log(unbondedProbeWaitingForDisLine()) + handler.removeCallbacks(unbondedProbeStartRunnable) + handler.postDelayed(unbondedProbeStartRunnable, UNBONDED_PROBE_DEFER_MS) + return + } + if (disChainInFlight) log(unbondedProbeStoppedWaitingLine(unbondedProbeDeferrals)) + val svc = g.getService(WHOOP5_SERVICE) ?: return + // Claim the link BEFORE queueing, so a keep-alive drain landing between the two cannot start a + // second probe against the same four characteristics. + unbondedProbeStartedThisLink = true + var queued = 0 + for (u in WHOOP5_NOTIFY_CHARS) svc.getCharacteristic(u)?.let { cccdQueue.add(it); queued++ } + // An empty queue drains INSTANTLY to the probe's own completion branch, which would announce + // "puffin chars subscribed — the strap serves them on an unencrypted link" having subscribed + // nothing at all. That is a fabricated finding in the one log this experiment exists to produce, + // so the claim is made only once there is something behind it. + if (queued == 0) { + log("Unbonded offload probe: the strap exposes none of the puffin notify chars on this link" + + " — nothing to subscribe, so there is no question to ask (#1635)") + return + } + unbondedProbeSubscribing = true + unbondedProbeSubscribed = 0 + unbondedProbeEvidence = UnbondedProbeEvidence.NONE + log(unbondedProbeStartLine()) + drainCccdQueue(g) + } + + /** + * Stage 1 came back refused — the answer the probe exists to obtain. + * + * Latched per device so the strap declines once rather than on every reconnect, with the same carve-out + * [noteReadFailure] makes: a link with an OS pairing in flight can fail a descriptor write for reasons + * that are ours, not the strap's, and latching that would blame the strap for our own timing forever. + */ + private fun noteUnbondedProbeRefused(uuid: java.util.UUID, status: Int) { + unbondedProbeSubscribing = false + unbondedProbeAwaitingReply = false + log(puffinSubscribeRefusedLine(uuid.toString(), gattWriteStatusLabel(status))) + if (explicitBondRequestedThisLink) { + log("Unbonded offload probe: not latching that refusal — a pairing was requested on this link," + + " so the failure is not attributable to the strap (#1635)") + return + } + runCatching { + unbondedOffloadRefusedPrefKey(lastDeviceAddress)?.let { + context.getSharedPreferences(com.noop.ui.NoopPrefs.NAME, android.content.Context.MODE_PRIVATE) + .edit().putBoolean(it, true).apply() + } + } + } + + /** + * Charge one link that subscribed the puffin characteristics and produced no answer, retiring the + * probe for this strap when the budget runs out. + * + * Both the "asked and heard nothing" path and the "link died mid-probe" path end here, and they must: + * a quiet link and a link torn down by the very subscriptions we wrote are the same evidence about + * this strap. Counting only the first is how the probe kept re-running across 24 connects. + */ + private fun chargeUnbondedProbeSilence() { + val spent = unbondedProbeSilentLinks + 1 + setUnbondedProbeSilentLinks(spent) + if (unbondedProbeStillWorthAsking(spent)) return + log(unbondedProbeGaveUpLine(spent)) + } + + /** + * Stage 2: the subscriptions landed, so ask the strap a read-only question. + * + * GET_CLOCK and not GET_DATA_RANGE, and certainly not SET_CLOCK. It changes nothing on the strap, so a + * strap that turns out not to be listening has had nothing done to it — and a COMMAND_RESPONSE to it is + * the first evidence in this whole thread that a puffin command written over an unencrypted link is + * parsed and answered rather than merely handed to the controller. + */ + private fun askUnbondedProbeQuestion() { + unbondedProbeAwaitingReply = true + unbondedProbeAskedAtMs = System.currentTimeMillis() + log(unbondedProbeAskingLine( + subscribed = unbondedProbeSubscribed, + total = WHOOP5_NOTIFY_CHARS.size, + waitMs = UNBONDED_PROBE_REPLY_WAIT_MS, + )) + // WITHOUT response, and that is not a detail. A with-response write to fd4b0002 is precisely what + // the CLIENT_HELLO does, and on this strap it never completes - leaving writeInFlight set and the + // whole write queue starved behind a callback that is never coming. Using it here would risk the + // working live-HR state to ask a question that does not need it: the evidence this probe wants is + // the strap's own COMMAND_RESPONSE arriving on the notify chars, not an ATT acknowledgement of the + // write. DISABLE_ALARM and TOGGLE_REALTIME_HR already go out this way on exactly these links. + send(CommandNumber.GET_CLOCK, byteArrayOf()) + handler.removeCallbacks(unbondedProbeVerdictRunnable) + handler.postDelayed(unbondedProbeVerdictRunnable, UNBONDED_PROBE_REPLY_WAIT_MS) + } + + /** + * Stage 2's verdict, and the handover to the ordinary offload if it passed. + * + * Runs on the timeout OR early on the first COMMAND_RESPONSE; [unbondedProbeAwaitingReply] makes it + * once-only, so the timer firing after an early success is a no-op rather than a second verdict. + * + * On success this sets [connectHandshakeDone] and NOTHING else about the bond state. `didBond` stays + * false deliberately: it is the record of an acked CLIENT_HELLO, which did not happen, and every one of + * its readers — the bond watchdog, the never-bonded self-drop counter, the stale-pairing guide — is + * reasoning about a handshake, not about whether history can flow. Setting it to unlock the offload is + * exactly the false-bond bug of 28 Aug, arrived at deliberately instead of by accident. + */ + private fun concludeUnbondedProbe() { + if (!unbondedProbeAwaitingReply) return + unbondedProbeAwaitingReply = false + val waited = System.currentTimeMillis() - unbondedProbeAskedAtMs + if (unbondedProbeEvidence != UnbondedProbeEvidence.ANSWERS_COMMANDS) { + log(unbondedProbeSilentLine( + waitedMs = waited, + sawNotifications = unbondedProbeEvidence == UnbondedProbeEvidence.SERVES_NOTIFICATIONS, + )) + // Not the refusal latch: the subscriptions were accepted, so this says the strap did not + // answer THIS time, and the carve-out the refusal path makes for a pairing in flight would + // apply here with less certainty, not more. Once-per-LINK does not bound it either — the probe + // re-runs on every reconnect — so silence spends a budget rather than writing a verdict from + // an ambiguous result. The budget is persisted, because the process is not a bound. + chargeUnbondedProbeSilence() + return + } + log(unbondedProbeAnsweredLine()) + log(unbondedProbeBacklogCaveatLine()) + // A genuine answer clears the silence budget: whatever the quiet links were, they were not this + // strap declining to talk, and a later reconnect — or a later app launch — must not inherit + // their count. + setUnbondedProbeSilentLinks(0) + // #1804: a genuine answer also clears the inconclusive budget — the local teardowns were not + // this strap refusing either, and the probe has now reached a conclusion. + setUnbondedProbeInconclusiveLinks(0) + // The proven 5/MG handshake tail, minus the hello that cannot happen: clock the strap, then offload. + // Clock-before-history is mandatory — an un-clocked 5/MG discards sensor data rather than banking it + // — and it is only reached here because the strap has just demonstrated it answers commands. + connectHandshakeDone = true + _state.update { it.copy(historyReady = true) } + // requestSync ALSO gates on state.bonded, and for a 5/MG that flag is set by the live-HR path - + // the first plausible reading over the standard profile. A strap sitting on a charger streams no + // HR, so without this the probe would succeed, announce it, and then be refused by a second gate + // in silence - on exactly the strap you would reach for history sync on. Same flag and same + // meaning the live-HR path sets ("the link is established"), NOT encryptedBond, which remains the + // record of a genuine handshake and stays false because none happened. + _state.update { it.copy(bonded = true) } + // Deliberately NOT startKeepAlive(), which the live-HR path pairs with this same flag. The + // keep-alive bounces a link that has been quiet for 120s, and a strap on a charger with no offload + // yet running is exactly that - so arming it here would tear down the stable link this probe spent + // three stages establishing. The link needs no keep-alive to be probed; it needs to be left alone. + noteRebootReconnectIfNeeded() + // Also without response, for the reason above. The bonded tail uses with-response here, but it + // runs on a link that completed a handshake; this one has proved only that the strap ANSWERS, not + // that it acknowledges. The offload that follows still writes GET_DATA_RANGE and + // SEND_HISTORICAL_DATA with response through the ordinary path, so if with-response is the thing + // this strap will not do, the log separates that cleanly from the stages above rather than + // wedging the queue before any of them can be read. + send(CommandNumber.SET_CLOCK, setClockPayload()) + send(CommandNumber.GET_CLOCK, byteArrayOf()) + if (!backfillStarted) { + backfillStarted = true + handler.postDelayed({ requestSync(BackfillTrigger.CONNECT) }, INITIAL_BACKFILL_DELAY_MS) + startBackfillTimer() + } + } + + /** + * Record what a puffin frame proves while the probe is listening. + * + * Called from the notify handler on the binder thread, which is why the conclusion hops to the main + * looper: [concludeUnbondedProbe] queues GATT writes and touches handler-scoped timers. + */ + private fun noteUnbondedProbeFrame(parsed: com.noop.protocol.ParsedFrame) { + if (!unbondedProbeAwaitingReply) return + unbondedProbeEvidence = strongerProbeEvidence( + unbondedProbeEvidence, + unbondedProbeEvidenceOf(ok = parsed.ok, crcOk = parsed.crcOk, typeName = parsed.typeName), + ) + if (unbondedProbeEvidence == UnbondedProbeEvidence.ANSWERS_COMMANDS) { + handler.post(unbondedProbeVerdictRunnable) + } } /** Chained second half of [readDisIdentity] — issued only after the serial read has landed. */ @@ -4405,10 +5612,12 @@ class WhoopBleClient( DIS_FW_REV_CHAR -> DIS_MANUFACTURER_CHAR DIS_MANUFACTURER_CHAR -> DIS_MODEL_NUMBER_CHAR DIS_MODEL_NUMBER_CHAR -> DIS_SW_REV_CHAR - else -> return + // Chain complete. Releasing the queue here is what lets the unbonded offload probe start + // without contending with it (#1635). + else -> { disChainInFlight = false; return } } - val g = gatt ?: return - val ops = gattOps ?: return + val g = gatt ?: run { disChainInFlight = false; return } + val ops = gattOps ?: run { disChainInFlight = false; return } val ch = g.getService(DIS_SERVICE)?.getCharacteristic(next) if (ch == null || (ch.properties and BluetoothGattCharacteristic.PROPERTY_READ) == 0) { // Not present on this strap: skip it and carry on down the chain rather than stopping. @@ -4431,6 +5640,10 @@ class WhoopBleClient( val prefix = disSerial?.trim()?.uppercase()?.take(3) ?: "?" log("DIS: serialPrefix=$prefix hwRev=${disHwRev ?: "?"} -> variant=${variant.label}") reconcileModelFromAttestation(variant) + // #1303: hand the strap's OWN serial up so the coordinator can re-point this pairing onto a stable + // `whoop-` id. Emitted, not acted on here, mirroring how the Oura source reports its serial: + // adoption re-points the ACTIVE device and tears down the very connection this callback runs inside. + disSerial?.let { onSerial?.invoke(it) } } /** The strap's own DIS attestation is ground truth (a WHOOP 4.0 never attests a 5AM/5AG serial). When @@ -4515,6 +5728,27 @@ class WhoopBleClient( } } + /** + * Ask the strap what alarm it currently has stored (#1706). The reply lands on the command-notify + * characteristic and is handled by the same GET_ALARM_TIME branch an arm's own follow-up read uses, + * so it persists `alarm.lastReportedEpoch` + the RAW response bytes for the debug export. + * + * Port of Swift `BLEManager.getStrapAlarm`, which until now had no caller on either platform. It + * exists here because the readback was otherwise only reachable by ARMING: a user whose alarm is off + * could not produce the evidence needed to explain what their strap reports, and on Android arming + * was the sole trigger. + * + * Not family-gated, unlike [armStrapAlarm] — but that is defensive, not a feature. [send] already + * no-ops when nothing is connected, and only the 4.0 branch decodes the reply, so an errant call + * costs one ignored write. The single caller today is the 4.0 side of the strap-alarm card, so the + * 5/MG frame is NOT currently captured by anything; a caller could be added there if that layout + * ever needs recording, and this method would not have to change. + */ + fun getStrapAlarm() { + send(CommandNumber.GET_ALARM_TIME, byteArrayOf(0x01)) + log("Alarm: requested current alarm time") + } + /** * Arm the strap's **firmware** alarm to buzz at [epochSec] (absolute UTC seconds). The strap fires * at that instant even if the phone is asleep or NOOP is closed. SET_CLOCK is sent first so the @@ -4563,6 +5797,9 @@ class WhoopBleClient( runCatching { val editor = NoopPrefs.of(context).edit() .putLong("alarm.lastArmSentEpoch", sentEpoch) + // #1706: WHICH strap this arm went to. Without it the export compares this epoch against + // a readback that, on a multi-strap install, may have come from a different device. + .putString("alarm.lastArmDeviceId", deviceId) .putLong("alarm.lastArmAt", System.currentTimeMillis()) .putBoolean("alarm.lastArmConnected", _state.value.connected) // #34: live HR at the moment of the arm, purely to test a hypothesis raised on a reporter's @@ -4669,6 +5906,33 @@ class WhoopBleClient( override fun onScanResult(callbackType: Int, result: ScanResult) { val device: BluetoothDevice = result.device val name = result.scanRecord?.deviceName ?: device.name ?: "unknown" + // The raw name goes to the DEVICE LIST (the user's own screen, where they need to recognise + // their strap); only the log gets the model-only form. See logSafeDeviceName. + val safeName = logSafeDeviceName(name) + // #1635: what the strap ADVERTISED, once per scan session. The open question on that issue + // is whether a refusing strap was in pairing mode, and a strap that accepts pairing should + // advertise differently — but nothing recorded the advertisement, so no log could say. + // Structure only, never payload: see ScanAdvertisementSummary. Test Centre gated. + if (!advertisementLogged && testCentre.active(com.noop.testcentre.TestDomain.CONNECTION)) { + advertisementLogged = true + val rec = result.scanRecord + log( + ScanAdvertisementSummary.line( + flags = rec?.advertiseFlags?.takeIf { it >= 0 }, + serviceUuids = rec?.serviceUuids?.map { it.uuid.toString() } ?: emptyList(), + serviceDataLengths = rec?.serviceData?.entries + ?.associate { it.key.uuid.toString() to (it.value?.size ?: 0) } ?: emptyMap(), + manufacturerDataLengths = (0 until (rec?.manufacturerSpecificData?.size() ?: 0)) + .associate { i -> + val k = rec!!.manufacturerSpecificData.keyAt(i) + k to (rec.manufacturerSpecificData.valueAt(i)?.size ?: 0) + }, + txPower = rec?.txPowerLevel?.takeIf { it != Int.MIN_VALUE }, + localNameLength = ScanAdvertisementSummary.localNameLength(rec?.deviceName), + connectable = result.isConnectable, + ), + ) + } // #716: the seeded "my-whoop" device has model "WHOOP" (no generation). Once a live // scan confirms which service family the strap advertises, stamp the correct model so // forRegistryModel returns the right DeviceFamily (fixes skin-temp ADC scale + display). @@ -4691,11 +5955,11 @@ class WhoopBleClient( val scanDecision = whoopGattScanDecision(selectedModel.service.toString(), advertisedServiceUuids) if (!scanDecision.shouldConnect) { scanDecision.unsupportedFamily?.let { family -> - log("Discovered $name (rssi ${result.rssi}) — ${family.diagnosticUnsupportedMessage}") + log("Discovered $safeName (rssi ${result.rssi}) — ${family.diagnosticUnsupportedMessage}") _state.update { it.copy(statusNote = family.diagnosticUnsupportedMessage) } return } - log("Discovered $name (rssi ${result.rssi}) without ${selectedModel.displayName} service — ignoring") + log("Discovered $safeName (rssi ${result.rssi}) without ${selectedModel.displayName} service — ignoring") return } // Multi-WHOOP present-scan (Add-a-device wizard, MW-4): accumulate the strap, do NOT @@ -4717,10 +5981,10 @@ class WhoopBleClient( // discovered" path below is byte-for-byte unchanged. val preferred = preferredAddress if (preferred != null && !device.address.equals(preferred, ignoreCase = true)) { - log("Discovered $name (${device.address}) — not the preferred strap; ignoring") + log("Discovered $safeName (${device.address}) — not the preferred strap; ignoring") return } - log("Discovered $name (rssi ${result.rssi}) — connecting") + log("Discovered $safeName (rssi ${result.rssi}) — connecting") // Found it: cancel the not-found timeout AND the family-rotation fallback, then reflect // progress in the UI. (PR#195) handler.removeCallbacks(scanTimeoutRunnable) @@ -4731,7 +5995,7 @@ class WhoopBleClient( _state.update { it.copy(statusNote = "Found $name, connecting…") } // Port of didDiscover: stop scanning, then connect to this peripheral. stopScan() - connectToDevice(device) + connectToDevice(device, alreadyAuthorised = true) // #1881: a scan result is already authorised } override fun onScanFailed(errorCode: Int) { @@ -4798,6 +6062,10 @@ class WhoopBleClient( * nag the user. Reset to 0 on any genuine bond. (5/MG firmware reset parity, 2026-06) */ private var staleDirectFailures = 0 + /** #1635: the stale-pairing clear is once per streak. Cleared by a genuine bond, the event that + * proves the phone and the strap agree again. */ + private var staleBondRemoved = false + /** * Which of OUR OWN paths last tore the link down, and when the current session started (#1020). * @@ -4932,8 +6200,14 @@ class WhoopBleClient( * off the involuntary-reconnect path on purpose: the streak must SURVIVE automatic reconnects (like * the #52 pinnedBondRefusals counter) so it can accumulate to the threshold across the strap dropping * and re-bonding. Only an explicit user tap (AppViewModel.connect) starts it over. Public so the - * ViewModel can call it; a thin wrapper over the private [clearPairingHint]. */ - fun clearPairingHintForUserConnect() = clearPairingHint() + * ViewModel can call it; a thin wrapper over the private [clearPairingHint]. + * + * #1635: does NOT drop the hello-suppression latch. The fresh handshake attempt a Connect grants is + * carried by [helloRetryRequested], not by clearing the latch, so keeping it costs the user nothing — + * and clearing it un-suppressed every AUTOMATIC reconnect that followed, which is what re-paid the + * full five refusals on every tap. Apple has always cleared it on a genuine bond and on forget only; + * this is the twin of that. */ + fun clearPairingHintForUserConnect() = clearPairingHint(genuineBond = false) /** Bonded-handshake watchdog (#50): every other connect phase has a timeout (scan; MTU settle delay; * keep-alive) but the post-discovery bond/CCCD handshake had none — so a WHOOP 4.0 that wedges @@ -5195,7 +6469,10 @@ class WhoopBleClient( // (the pairing hint has had several cycles to be acted on), pause auto-reconnect so we stop hammering // a strap that can't bond, write the one-line epitaph (opaque hashed id only, no PII), and surface // the honest paused hint. A genuine bond or a manual reconnect re-arms it. - if (bondGiveUp.recordRefusal()) { + // #1635: the two causes want different patience, for the same reason they want different + // outcomes — see [giveUpThresholdFor]. Keyed on the same authRefusal as the branch below, so the + // threshold and the treatment can never disagree about which kind of refusal this is. + if (bondGiveUp.recordRefusal(giveUpThresholdFor(authRefusal, bondGiveUp.giveUpThreshold))) { // #1635: an unanswered handshake and an auth refusal want opposite treatment — see // [giveUpSuppressesHello]. Suppressing keeps a link that is streaming live HR; pausing is for a // strap that actively declined and cannot be helped by reconnecting. @@ -5231,11 +6508,21 @@ class WhoopBleClient( /** Clear the pairing-hint streak + published hint after a genuine bond or a fresh connect. Also clears * the mirrored [statusNote] only when it still carries the hint, so we never wipe an unrelated note. */ - private fun clearPairingHint() { + private fun clearPairingHint(genuineBond: Boolean = true) { bondRefusalStreak = 0 // #1635: a genuine bond proves the handshake works on this strap — drop the suppression latch so a // later transient failure starts from a clean slate rather than inheriting an old verdict. - runCatching { com.noop.ui.NoopPrefs.setHelloSuppressed(context, lastDeviceAddress, false) } + // + // Only a genuine bond. A user Connect passes false: it already gets its fresh attempt from + // [helloRetryRequested], and dropping the latch as well left every automatic reconnect after that + // attempt un-suppressed, so the give-up had to re-earn itself over five more refusals — ~55s of + // link churn per tap, on a strap whose firmware cannot answer the handshake either way. + if (pairingHintClearDropsSuppressionLatch(genuineBond)) { + runCatching { com.noop.ui.NoopPrefs.setHelloSuppressed(context, lastDeviceAddress, false) } + } + // #1635: the pairing request is asked again from here too, so its one-shot line must be able to + // report a SECOND retirement. Without this the switch would go quiet for good after one round. + explicitBondGiveUpLogged = false // #747/#750: a genuine bond or a fresh user connect re-arms auto-reconnect and clears the give-up. bondGiveUp.reset() // #971: a genuine bond or a fresh user connect also clears the bond-watchdog bounce streak, so the @@ -5306,7 +6593,21 @@ class WhoopBleClient( } @SuppressLint("MissingPermission") - private fun connectToDevice(device: BluetoothDevice, autoConnect: Boolean = false) { + private fun connectToDevice( + device: BluetoothDevice, + autoConnect: Boolean = false, + /** + * #1881: true when the decision to connect was ALREADY authorised upstream — the user's explicit + * Connect, or a scan result, which can only exist because a gated-or-user scan is running. Every + * other caller is automatic (the radio-on re-arm, the reconnect backoff, launch auto-reconnect + * #67) and defaults to false, which is what the gate is for. + * + * Threaded rather than gated once upstream because, unlike Swift, these callers do NOT all funnel + * through a single system entry point. + */ + alreadyAuthorised: Boolean = false, + ) { + if (!alreadyAuthorised && !whoopConnectAllowed("connect-to-device")) return // Reset per-connection state (mirrors the Swift flags cleared on connect/disconnect). reset() // Remember the device so a later dropout can reconnect straight to it (#61). @@ -5369,6 +6670,9 @@ class WhoopBleClient( // #1030 (ryanbr): a real link is up — cancel any pending involuntary reconnect so a // stale backoff timer can't fire and reset+close this connection. cancelPendingReconnect() + // #1881: attribute this link to the strap that actually connected, before anything + // persists. Swift twin: `BLEManager.didConnect` -> `adoptSourceIdentity(for:)`. + adoptSourceIdentity(runCatching { g.device?.address }.getOrNull()) // A successful connect clears the reconnect backoff — the next involuntary drop starts // the 3,6,12…s schedule afresh (iOS didConnect: failedConnectAttempts=0, #48). Reset // IMMEDIATELY, not behind a survival dwell: a band the OS holds bonded/ACL-connected @@ -5383,6 +6687,19 @@ class WhoopBleClient( // exactly what distinguishes a healthy connection from the flapping loop that keeps // the reconnect streak — and so the scan mode — pinned at its most power-hungry. linkUpSinceMs = System.currentTimeMillis() + // #1809: this link's inbound tally starts empty, so the epitaph on disconnect reports + // exactly what arrived on THIS link and never a previous session's traffic. + inboundFrames = 0; inboundBytes = 0; cmdChannelFrames = 0 + // Same guarantee for the rejection tally: a link that opens without a preceding clean + // teardown would otherwise report the previous link's rejections as its own. + rejectTally.reset(); loggedRejectReasons.clear() + // #1635: same guarantee for the banked tally. Clearing only on teardown would be enough if + // every link ended in one; a link that begins without a preceding clean teardown would + // otherwise open holding the previous link's rows and report them as banked on this one. + liveHr.set(0); liveRr.set(0); offloadHr.set(0); offloadRr.set(0) + offloadGravity.set(0); offloadResp.set(0); offloadSkinTemp.set(0) + offloadSpo2.set(0); offloadSteps.set(0); offloadChunks.set(0) + realtimeArmedThisLink = false // A connect succeeded → clear the stale-bond re-pair guide UNLESS we are in a known // bond-loop (#617). In that loop the strap "connects" every ~3 s before timing out // again, so clearing here wiped the guide on EVERY cycle: it flashed for ~1 s and @@ -5598,6 +6915,42 @@ class WhoopBleClient( ), com.noop.testcentre.TestDomain.CONNECTION) } } + // #1949: and what we HAVE with the strap, next to what it offers. The sibling dump above + // covers fd4b0002, the write char; this covers the four NOTIFY chars the offload and the + // realtime IMU producer both arrive on. A capture showed all four discovered, one + // standard-HR subscribe, and nothing more — which reads identically to the strap refusing + // them. Only HEART_RATE_CHAR and BATTERY_CHAR reach `cccdQueue` below on a 5/MG, so + // "subscribed=no" here is the app's own doing and the log should say so rather than + // leave it to be inferred. Local reads only, like its sibling: no GATT operation. + // + // Per CONNECT, unlike the tree above which is once per strap, and the difference is the + // point: the tree is static for a device while this is exactly the state that changes + // between links — a bond acquired, a hello written, the opt-in flipped. Seven lines + // against that sibling's thirty, so a reconnect loop costs a quarter as much as the + // enumeration that deliberately declined to repeat. + if (testCentre.active(com.noop.testcentre.TestDomain.CONNECTION)) { + val notifyDump = runCatching { + WHOOP5_NOTIFY_CHARS.mapNotNull { u -> + whoop5.getCharacteristic(u)?.let { ch -> + NotifyCharDump( + uuid = u.toString().take(8), + hasCccd = ch.getDescriptor(CCCD) != null, + ) + } + } + }.getOrDefault(emptyList()) + for (line in whoop5PairingDumpLines( + bondState = runCatching { g.device.bondState }.getOrDefault(-1), + didBond = didBond, + helloWrittenThisLink = helloWrittenThisLink, + probeOptedIn = runCatching { + PuffinExperiment.from(context).unbondedOffload + }.getOrDefault(false), + notifyChars = notifyDump, + )) { + log(line, com.noop.testcentre.TestDomain.CONNECTION) + } + } } else { log("Custom WHOOP service not found on this peripheral") } @@ -5729,16 +7082,43 @@ class WhoopBleClient( // just-works bonding completed. Now subscribe the puffin notify chars (realtime HR rides // these as REALTIME_DATA — the strap rejected them on the unauthenticated link), then arm // realtime HR with puffin framing. Mirrors the macOS post-bond flow. + // A completed write means the STACK finished it. Encryption is a property of the + // link, and on Android only the OS bond state attests it — so the two are split here + // rather than inferred from one another. See [helloCompletionProvesEncryptedBond]: + // the field capture has "acked after 5ms" and "BOND_NONE" two seconds apart on the + // same link, for a strap an HCI capture shows refusing SMP outright. + val encrypted = helloCompletionProvesEncryptedBond( + osBonded = g.device.bondState == BluetoothDevice.BOND_BONDED, + ) didBond = true - cancelBondWatchdog() // genuine bond reached — the handshake watchdog stands down (#50) - noteGenuineBond(g.device.address) // #52: this strap bonds fine; clears any pin-refusal streak - clearPairingHint() // #78: a genuine bond means the pairing guidance no longer applies + cancelBondWatchdog() // the hello is answered either way — nothing left to time out (#50) bondedDirectAttempt = false // fast-path connect reached a real session (#78 fork) - staleDirectFailures = 0 // genuine bond — clear the wiped-bond counter (#84 parity) - _state.update { it.copy(bonded = true, encryptedBond = true) } // genuine bond (#69) + // Everything below claims something about the STRAP'S WILLINGNESS TO BOND, and a write + // completion is not evidence of that. Clearing them on an unencrypted completion is how + // a strap that never bonds gets recorded as one that bonds fine — and it retires the + // very counters that exist to notice it never did. + if (encrypted) { + setHelloDeferredRun(0) // the handshake works on this strap; the run is over + helloDeferredGuidanceLogged = false + helloOverrideAttempts = 0 // #1635: the strap answered — the override's budget resets + helloOverrideExhaustedLogged = false + noteGenuineBond(g.device.address) // #52: this strap bonds fine; clears any pin-refusal streak + clearPairingHint() // #78: a genuine bond means the pairing guidance no longer applies + staleDirectFailures = 0 // genuine bond — clear the wiped-bond counter (#84 parity) + staleBondRemoved = false // ...and re-arm the one-shot stale-pairing clear + } + _state.update { it.copy(bonded = true, encryptedBond = encrypted) } bondedAtMs = System.currentTimeMillis() // #617: stamp the bond so handleDisconnect can spot a bond-then-quick-timeout loop - emitConnectionBondState("encryptedBond family=whoop5 (CLIENT_HELLO acked)") - log("WHOOP 5/MG: CLIENT_HELLO acked — link established; subscribing notify chars (experimental).") + if (encrypted) { + emitConnectionBondState("encryptedBond family=whoop5 (CLIENT_HELLO acked)") + log("WHOOP 5/MG: CLIENT_HELLO acked — link established; subscribing notify chars (experimental).") + } else { + log(helloAckedWithoutEncryptionLine( + elapsedMs = if (clientHelloWriteAtMs > 0L) + System.currentTimeMillis() - clientHelloWriteAtMs else -1L, + osBondState = bondStateName(g.device.bondState), + )) + } g.getService(WHOOP5_SERVICE)?.let { svc -> for (u in WHOOP5_NOTIFY_CHARS) svc.getCharacteristic(u)?.let { cccdQueue.add(it) } } @@ -5754,7 +7134,7 @@ class WhoopBleClient( // window would re-arm the stream from it and stay armed until the next tick. val realtimeWantNow = screenWantsRealtime || continuousCaptureWantsNow() wantsRealtime = realtimeWantNow - if (realtimeWantNow) { realtimeArmed = true; send(CommandNumber.TOGGLE_REALTIME_HR, byteArrayOf(1)) } + if (realtimeWantNow) { realtimeArmed = true; realtimeArmedThisLink = true; send(CommandNumber.TOGGLE_REALTIME_HR, byteArrayOf(1)) } } } else if (!didBond && connectedFamily == DeviceFamily.WHOOP4) { didBond = true @@ -5773,6 +7153,7 @@ class WhoopBleClient( // WHOOP 5.0/MG uses CLIENT_HELLO, not this WHOOP4 command sequence, so it is skipped for it. if (!connectHandshakeDone && connectedFamily == DeviceFamily.WHOOP4) { connectHandshakeDone = true + _state.update { it.copy(historyReady = true) } noteRebootReconnectIfNeeded() runConnectHandshake() } @@ -5807,8 +7188,19 @@ class WhoopBleClient( ) { if (status != BluetoothGatt.GATT_SUCCESS) { log("Notify enable failed for ${descriptor.characteristic?.uuid}: status=$status") + // #1635: on the puffin notify chars during the unbonded probe, this IS the result — the + // generic line above cannot say so, because it is written for a transient stack failure + // and this is a verdict about whether the offload needs an encrypted link at all. + descriptor.characteristic?.uuid?.let { u -> + if (unbondedProbeSubscribing && u in WHOOP5_NOTIFY_CHARS) noteUnbondedProbeRefused(u, status) + } } else { log("Subscribed ${descriptor.characteristic?.uuid}") + // #1635: only a CONFIRMED puffin subscribe counts toward the probe's claim that the strap + // serves these chars unbonded. + descriptor.characteristic?.uuid?.let { u -> + if (unbondedProbeSubscribing && u in WHOOP5_NOTIFY_CHARS) unbondedProbeSubscribed++ + } // A subscribe landed — replenish the shared BUSY-retry budget so a transient stall on // one characteristic can't starve the others' retries (the counter is global). cccdRetries = 0 @@ -5865,7 +7257,78 @@ class WhoopBleClient( // MARK: Inbound routing (port of didUpdateValueFor + FrameRouter.handle) // ==================================================================================== + /** #1809: per-connection inbound accounting for the link epitaph - the strap log could not previously + * say whether the strap transmitted anything at all. Twin of the Swift counters. */ + private var inboundFrames = 0 + private var inboundBytes = 0 + private var cmdChannelFrames = 0 + + /** + * Frames this link REJECTED, per reason, plus the one named counter for the class that used to pass + * the gates (payload CRC32 verified, envelope not). Per CONNECTION, because that is the unit the + * readout is about; cleared with the inbound tally at connect and at teardown. Twin of the Swift + * `FrameRouter.rejectTally`. + */ + private val rejectTally = FrameRejectTally() + + /** + * Reasons already reported on this connection, so the Test Centre line is one per REASON rather than + * one per frame: a noisy link rejects continuously, and a per-frame line would bury the transition + * that carries the information. + */ + private val loggedRejectReasons = HashSet() + + // #1635: rows ACCEPTED on this link, split by PATH. The realtime decoder (`extractStreams`) only + // ever produces hr/rr/events/battery; gravity, resp, skinTemp, spo2 and steps arrive solely through + // the offload's historical decoder. Counting one path and naming streams from the other printed a + // constant dressed as a finding, which is what the live-only first cut did. + // + // ATOMIC because the writers are not one thread: the live pair is folded in from `flushLive` and + // `flushStandardHr`, two independent `ioScope.launch` coroutines, the offload pair from the + // Backfiller's own coroutine, and the teardown read is a third context again. + private val liveHr = java.util.concurrent.atomic.AtomicInteger(0) + private val liveRr = java.util.concurrent.atomic.AtomicInteger(0) + private val offloadHr = java.util.concurrent.atomic.AtomicInteger(0) + private val offloadRr = java.util.concurrent.atomic.AtomicInteger(0) + private val offloadGravity = java.util.concurrent.atomic.AtomicInteger(0) + private val offloadResp = java.util.concurrent.atomic.AtomicInteger(0) + private val offloadSkinTemp = java.util.concurrent.atomic.AtomicInteger(0) + private val offloadSpo2 = java.util.concurrent.atomic.AtomicInteger(0) + private val offloadSteps = java.util.concurrent.atomic.AtomicInteger(0) + /** Chunks the offload actually persisted on this link. Separates "never ran" from "nothing new". */ + private val offloadChunks = java.util.concurrent.atomic.AtomicInteger(0) + + /** Fold one LIVE persist round into the per-link tally (hr/rr are all the realtime decoder yields). */ + private fun addBankedLive(c: InsertCounts) { + liveHr.addAndGet(c.hr); liveRr.addAndGet(c.rr) + } + + /** Fold one OFFLOAD persist round in. This is where the bond shows: an unbonded strap defers it. */ + private fun addBankedOffload(c: InsertCounts) { + offloadChunks.incrementAndGet() + offloadHr.addAndGet(c.hr); offloadRr.addAndGet(c.rr) + offloadGravity.addAndGet(c.gravity); offloadResp.addAndGet(c.resp) + offloadSkinTemp.addAndGet(c.skinTemp); offloadSpo2.addAndGet(c.spo2) + offloadSteps.addAndGet(c.steps) + } + /** #1809: was realtime armed at ANY point on THIS link. Not the same thing as [realtimeArmed], which + * is persistent edge state: it can carry true in from a previous link, or read false at the drop + * after a mid-link disarm. The epitaph needs the per-link fact, which is what the Apple twin's + * `realtimeArmedAt` gives (it is cleared on every disconnect). Reset with the counters on connect. + * + * @Volatile for the same reason [realtimeArmed] is: the arm sites include the reconcile path, which + * does not run on the GATT callback thread that reads this at the drop. A stale read here would print + * "armed=no" for a link that WAS armed, and that word is what a reader uses to decide whether #80 + * applies. The frame/byte counters above are deliberately plain - ++ is not atomic whatever we + * annotate them with, and an approximate count still answers "did anything arrive at all". */ + @Volatile private var realtimeArmedThisLink = false + private fun onInbound(uuid: UUID, bytes: ByteArray) { + // Count BEFORE any routing below, so the tally covers every inbound frame including ones no branch + // consumes - the epitaph must answer "did anything arrive at all". + inboundFrames++ + inboundBytes += bytes.size + if (uuid == CMD_NOTIFY_CHAR) cmdChannelFrames++ lastDataAtMs = System.currentTimeMillis() // feeds the keep-alive liveness watchdog resubscribedSinceData = false // data is flowing again — re-arm the one-shot resubscribe when { @@ -5930,7 +7393,15 @@ class WhoopBleClient( uuid in WHOOP5_NOTIFY_CHARS -> { // Reassemble (no-op for already-complete frames) then route each complete frame. // Port of: for frame in reassembler.feed(bytes) { router.handle(frame:) }. - for (frame in reassembler.feed(bytes)) { + // + // A byte run whose declared total is below the family minimum is dropped INSIDE the + // reassembler and reaches no parser and no archive, so its monotonic drop count is folded + // into the connection's reject tally right after the feed that may have grown it — + // otherwise it would disappear without trace. Idempotent, so once per notification is + // both correct and cheap. Twin of the Swift `router.noteReassemblerDrops`. + val completedFrames = reassembler.feed(bytes) + rejectTally.absorbReassemblerDrops(reassembler.belowMinimumLengthDrops) + for (frame in completedFrames) { // #453 defense-in-depth: this loop runs on the GATT binder thread; an uncaught throw // from ANY frame op (handleFrame, a decoder, the inline date-format, log) would crash // the whole app — the exact chain the redactPii bug escaped through. Wrap the whole @@ -5940,14 +7411,26 @@ class WhoopBleClient( // handleFrame's replayedOffload gate, so evaluating it twice bounds-checked + indexed // every offloaded frame for nothing. (The Swift 5/MG inbound loop already hoists this.) val offloadFrame = backfilling && isOffloadFrame(frame, connectedFamily) + stopUnexpectedRealtimeImu(frame, offloadFrame) noteWhoop5R22Telemetry(frame, offloadFrame) // #174 // #47: decode this frame ONCE and thread it to both consumers (the router below and the // live collector) instead of each re-parsing it — steady-state drops 2→1 parse per live // frame. Family-aware, so it's correct for WHOOP4 and 5/MG alike. val parsed = Framing.parseFrame(frame, connectedFamily) + // #1635: while the unbonded probe is listening, this frame is the measurement. Gated + // inside the call so a normal link pays one boolean read per frame. + noteUnbondedProbeFrame(parsed) // A frame replayed as part of the historical offload (type 47/48/… during a backfill) - // must not drive LIVE-only state (the charging pill). Mirrors iOS, where the offload - // path skips the live router entirely. (PR #568 reimpl) + recordGroundTruthImuFrame(frame) + // must not drive LIVE-only state (the charging pill). (PR #568 reimpl) + // + // NOT the same shape as iOS, despite what this said before. THIS side calls the handler + // for EVERY frame and gates only the live-only effects; iOS skips the router outright + // for offload frames and carves out the few things that must still fire (a live + // gesture, and now the strap's console narration). Same outcome for the charging pill, + // opposite structure — so a reader porting behaviour either way has to check which + // frames reach the handler at all, not assume it matches. That assumption is exactly + // how the console mirror shipped dead on iOS: decoded, keyed correctly, never called. handleFrame(frame, parsed, replayedOffload = offloadFrame) // Capture the strap's newest stored record from a GET_DATA_RANGE reply, feeding @@ -5991,6 +7474,20 @@ class WhoopBleClient( _bodyLocationProbe.value = text if (payHex != null) NoopPrefs.of(context).edit().putString(KEY_690_PREV_PAYLOAD, payHex).apply() } + // cmd-151 battery-pack probe: decode the pack's charge/serial/address into the Devices + // dialog and the strap log. In-flight-guarded like #690 above — 0x97 could + // coincidentally be some data frame's cmd-offset byte, and this is strictly a + // user-triggered diagnostic, so a stray match must never pop the result dialog. + if (frame.size > cmdOff && (frame[cmdOff].toInt() and 0xFF) == CommandNumber.GET_BATTERY_PACK_INFO.rawValue && + _batteryPackProbe.value == WAITING_BATTERY_PACK_PROBE) { + val text = formatBatteryPackProbe(frame, cmdOff) + log("Battery-pack probe (151):\n$text") + // Scrub the DIALOG copy too. log() already redacts on the way to the strap log, + // but this value is rendered behind a copy-to-clipboard button, so it is a second + // sink the scrubber otherwise never sees — and the `raw:` frame dump carries the + // serial as ASCII inside the hex, which is exactly what redactStrapLogPii masks. + _batteryPackProbe.value = redactStrapLogPii(text) + } // #761: a reply to the read-only feature-flag enumeration (117/118). In-flight-guarded // inside handleFeatureFlagProbeResponse, so this is a byte compare on every other frame. if (frame.size > cmdOff && @@ -6036,6 +7533,18 @@ class WhoopBleClient( handleBroadcastHrGateReadBack(frame, connectedFamily == DeviceFamily.WHOOP5) } } + // WHAT IS AND IS NOT GATED HERE (standing risk, recorded rather than fixed). + // Every other branch in this dispatch block keys on a RAW BYTE COMPARE of the command + // opcode, before any integrity verdict, and that is unchanged: the state-driving + // consumers are gated (the router above, the history-metadata classifier, both + // extractors and this reply), and this change does not claim "every frame consumer". + // Of the probe replies dispatched above, the feature-flag probe and the device-config + // READ probe verify inside their own decoder (`Framing.frameCrcOk`); the extended- + // battery (#592), body-location (#690) and battery-pack probes and the write-ack + // branches do NOT, and stay that way — none drives live state or the offload. The + // exposure there is a diagnostic asserting something the strap never said: a real + // violation of "a diagnostic may only assert what it can attribute", but a reading, + // not a data path. Hardening them is a scope decision, not this change's. if (frame.size > cmdOff && (frame[cmdOff].toInt() and 0xFF) == CommandNumber.GET_DATA_RANGE.rawValue) { // #451: dump raw GET_DATA_RANGE response bytes unconditionally (even if decode returns // null) so a stale/wrong-epoch "newest" can be told apart from a frame-alignment bug in @@ -6048,16 +7557,49 @@ class WhoopBleClient( // logged NOTHING — a strap log was indistinguishable from one where the strap never // answered, which is why a broken decode survived unnoticed. The raw-frame dump above // is unconditional for the same reason. Twin of the Swift branch. - 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)") - } else { + // ...but NOT on the PENDING(2) ack. GET_DATA_RANGE answers twice — a short ack, then + // the payload — which Framing's result-code table already records ("2=PENDING + // precedes SUCCESS on GET_DATA_RANGE"). The ack carries no ring pointers at all, so + // decoding it failed every time and reported a decode problem that did not exist: + // one "offsets may have moved" per sync on healthy hardware, pointing the reader at + // an alignment bug rather than at the ack. Skip it here; a SUCCESS frame that will + // not decode still logs loudly, which is the case the paragraph above protects. + if (!com.noop.protocol.DataRange.isPendingResponse(frame, cmdOff)) { + 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)") + } 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.", + ) + } + } + // GATED ON THE FULL VERDICT from here down, unlike the probe replies above, + // because this one has OFFLOAD effect: the newest/oldest words it yields become + // the plausibility window every drained record is checked against (#547). A + // damaged reply that narrows that window makes the real records of the same sync + // fall through it — the section then persists nothing, and is acknowledged + // anyway, which is the same permanent loss the integrity gate exists to stop. + // `parsed` is the single parse from the seam above, so this costs one Boolean + // read and no re-parse. Twin of the Swift BLEManager `handleDataRangeResponse` + // gate; the raw dump and the #689 backlog line ABOVE stay ungated on purpose, + // because a damaged range reply is exactly the event a strap log needs to show, + // and neither of them touches sync state. + // The decision itself lives in `DataRange.acceptsReply`, the twin of the Swift + // predicate, so a unit test can hold the gate instead of only the parse. + val acceptsWindow = com.noop.protocol.DataRange.acceptsReply( + frame, cmdOff, CommandNumber.GET_DATA_RANGE.rawValue, + ) { parsed.ok } + if (!acceptsWindow) { 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.", + "Get Data Range reply REJECTED by the frame-integrity check " + + "(reason=${parsed.rejectReason.wireName}) — the strap's banked-record " + + "window is left unchanged (#547 plausibility bounds keep their previous " + + "values). The raw frame above is the evidence.", ) } - dataRangeNewestUnix(frame)?.let { + (if (acceptsWindow) dataRangeNewestUnix(frame) else null)?.let { strapNewestTs = it // Capture the wall clock of THIS reading so the backfiller correlation pairs // the strap's device time with the wall time of the same instant (see field doc). @@ -6107,6 +7649,27 @@ class WhoopBleClient( ) log(line, com.noop.testcentre.TestDomain.UNIVERSAL) } + // #1164: recompute the "strap has banked records newer than our frontier" flag + // so the Today Rest card can show "Pending sync" right after connect (before the + // first offload starts), not only after an offload completes. The frontier read + // is async; the flag settles a beat after the range lands. + val newestForPending = it + ioScope.launch { + val f = runCatching { repository.latestHrSampleTs(deviceId) }.getOrNull() + if (f != null) { + // #928/#1012: a strap whose clock is set in the FUTURE reads ahead + // of ANY frontier, so without this the gap never closes and Rest is + // pinned to "Pending sync" for good. The phantom-gap guard used at the + // post-offload site cannot apply here: no offload has run yet, so there + // is no row evidence to weigh. The first completed pass corrects it. + val wallNowP = System.currentTimeMillis() / 1000L + val p = !isFutureDatedNewest(newestForPending, wallNowP) && + (newestForPending - f) > AUTO_CONTINUE_BEHIND_GAP_SECONDS + if (_state.value.historyPendingSync != p) { + _state.value = _state.value.copy(historyPendingSync = p) + } + } + } } } @@ -6122,7 +7685,6 @@ class WhoopBleClient( // BEFORE the offload branch so it catches the burst; no-op unless capture is on. writeWhoop5DeepBufferIfBig(uuid.toString(), frame, isOffloadFrame(frame, connectedFamily)) // #423: the queryable twin of that diagnostics line — persist the decoded IMU - // samples (100 Hz 6-axis) into the rawImuSample table when raw capture is on. storeWhoop5RawImuIfBuffer(frame) } // Opt-in raw capture: record EVERY frame of the session (offload AND live flood — @@ -6225,6 +7787,43 @@ class WhoopBleClient( } } + /** + * Count one rejected frame and say something about it exactly once (D3). + * + * Two different visibility rules, on purpose: + * + * - The class where the payload CRC32 VERIFIED while the envelope did not is announced always-on, + * at its first sighting. It is the class that passed every gate before this change, it is what + * the hardware run's abort criterion reads, and it costs nothing on a link where it never + * happens — which is the whole point of leaving rare-event evidence unconditional. + * - The ordinary per-connection detail sits behind the Test Centre's Connection domain, one line + * per REASON. A resync after a lost notification rejects frames routinely and always has; a line + * per frame would be noise, and the general per-reason counter is explicitly NOT the abort signal. + * + * Each line reports only what the parse result observed: the reason the verifier gave, and the + * packet type the decoder actually read (a rejected frame keeps it). Twin of the Swift + * `FrameRouter.noteRejectedFrame`. + */ + private fun noteRejectedFrame(parsed: com.noop.protocol.ParsedFrame) { + val hadAdmittedClass = rejectTally.payloadCrcOkButEnvelopeRejected > 0 + val reason = rejectTally.note(parsed) + if (!hadAdmittedClass && rejectTally.payloadCrcOkButEnvelopeRejected > 0) { + log( + "Frame rejected while its payload CRC32 verified " + + "(reason=${reason.wireName}, type=${parsed.typeName}) — the frame class that " + + "reached live state before the integrity gate.", + ) + } + if (testCentre.active(com.noop.testcentre.TestDomain.CONNECTION) && + loggedRejectReasons.add(reason) + ) { + log( + "frameReject reason=${reason.wireName} type=${parsed.typeName}", + com.noop.testcentre.TestDomain.CONNECTION, + ) + } + } + /** * Pure decode→state router for one COMPLETE frame. * Direct port of `FrameRouter.handle(frame:)`. @@ -6238,9 +7837,15 @@ class WhoopBleClient( * instead of twice (this router path + the live-collector flush). `frame` is still passed for the * byte-level sub-decoders. */ private fun handleFrame(frame: ByteArray, parsed: com.noop.protocol.ParsedFrame, replayedOffload: Boolean = false) { - if (!parsed.ok) return - // Reject frames that failed their checksum — never let bad bytes drive state. - if (parsed.crcOk == false) return + // ONE gate, the verifier's FULL verdict: header checksum, payload CRC32 and structural length + // together. This used to be two steps — a parse-succeeded flag, then a separate payload-CRC + // check — and between them sat the class this change closes: a frame whose payload CRC32 is + // right while its header checksum or declared length is not. Never let bad bytes drive state. + // Twin of the Swift `FrameRouter.handle`. + if (!parsed.ok) { + noteRejectedFrame(parsed) + return + } // Connection test mode: accumulate frames by type and flush ONE `frameTiming` SUMMARY line per // rolling window (#1151), instead of a line per frame-TYPE transition — during offloads/command @@ -6262,6 +7867,12 @@ class WhoopBleClient( // Only republish when the value actually changed: a same-HR frame's it.copy() allocates a // whole throwaway LiveState that StateFlow drops as equal anyway — pure GC churn at ~1 Hz, // every frame. Matches the Swift FrameRouter guard (`state.heartRate != hr`). + // #1865: only a LIVE sample restarts the stall clock. Replayed offload frames reach + // this branch too (handleFrame carries `replayedOffload`, and stopUnexpectedRealtimeImu + // gates on it for exactly that reason), so counting them would let a history sync mask + // a dead realtime stream — which is the very shape of the bug this recovers from: + // healthy-looking traffic hiding the absence of HR. + if (hr in 30..220 && !replayedOffload) noteLiveHr() if (hr in 30..220 && _state.value.heartRate != hr) _state.update { it.copy(heartRate = hr) } } // The realtime stream usually reports rr_count=0; only update R-R when this frame @@ -6315,9 +7926,14 @@ class WhoopBleClient( // falls outside and is withheld inside the probe rather than by this caller. // knownNameOffset = -1 because 16 is the 5/MG device-name offset and means nothing in a // cmd-35 payload. Log-only; decodes/persists nothing. Twin of the Swift FrameRouter probe. - if (connectedFamily == DeviceFamily.WHOOP4 && respCmd?.startsWith("GET_HELLO_HARVARD") == true && - testCentre.active(com.noop.testcentre.TestDomain.CONNECTION)) { + if (connectedFamily == DeviceFamily.WHOOP4 && respCmd?.startsWith("GET_HELLO_HARVARD") == true) { val helloPay = whoop4CommandResponsePayload(frame) ?: ByteArray(0) + // #1193: the identity read is UNGATED, unlike the probe below it. Adoption has to work + // for every 4.0 user, and Test Centre is off for almost all of them — gating it would + // ship a stable id only to the people already debugging. The decoder reads a fixed + // 9-byte window and can never reach the device key beside it. + com.noop.protocol.Whoop4HelloSerial.decode(helloPay)?.let { noteHarvardSerial(it) } + if (testCentre.active(com.noop.testcentre.TestDomain.CONNECTION)) { log( com.noop.protocol.HelloIdentityProbe.report( helloPay, @@ -6325,6 +7941,7 @@ class WhoopBleClient( knownNameOffset = -1, ) + " — locate the strap serial offset (#1303)", ) + } } // #1303: the 5/MG half of the same hunt. The 4.0 aid above is 4.0-only — correctly, since // a 5/MG never answers cmd 35 — so this family had no capture at all, and it needs one @@ -6351,6 +7968,35 @@ class WhoopBleClient( ) } } + // The 5/MG battery pack (cmd 151). `BatteryPackInfo` has decoded this reply since its + // offsets were captured, and until now nothing sent the command — so the decoder had no + // caller and the offsets have never been seen against a live strap. + // + // LOG-ONLY, deliberately. Those offsets are an unvalidated candidate re-derived from two + // frames, and a wrong one does not fail: it renders a confident wrong number. So this + // reports what it read AND whether it passes the `displayable` sanity check, which is the + // evidence a card needs before it can honestly show anything. Twin of the Swift + // FrameRouter branch. Test Centre gated; persists nothing. + if (connectedFamily == DeviceFamily.WHOOP5 && + respCmd?.startsWith("GET_BATTERY_PACK_INFO(") == true && + testCentre.active(com.noop.testcentre.TestDomain.CONNECTION) + ) { + val info = com.noop.protocol.BatteryPackInfo.decode(frame) + if (info != null) { + val soc = info.socPct?.let { String.format("%.1f%%", it) } ?: "—" + // logSafe, NOT the raw serial. The log redaction keys on a literal "WHOOP " prefix + // or a `whoop-` id, and a bare `serial=BB5AP…` matches neither — so the rule that + // protects the strap's serial would have let the pack's through to an exportable + // log. Three characters is enough to tell two packs apart. + val safeSerial = com.noop.data.WhoopSerialIdentity.logSafe(info.serial) + log( + "[pack] present=${info.present} soc=$soc serial=$safeSerial " + + "displayable=${info.displayable} (#1303)", + ) + } else { + log("[pack] cmd 151 replied but did not decode — offsets may have moved") + } + } // Reboot ack (#166): log the COMMAND_RESPONSE result for a user reboot on BOTH families — // the accept/reject signal (the same one that exposed 5/MG haptics rejection). So a 5/MG // owner's strap log confirms whether the (unverified) puffin reboot frame is accepted. The @@ -6365,6 +8011,19 @@ class WhoopBleClient( } log("reboot: strap acked result=${result ?: "none"} ($verdict)") } + // #1823: the clock exchange, on BOTH families. NOOP wrote "clock synced" the moment it + // queued the writes and never read the answer, so a strap log asserted the clock was set + // while the readout said 1970/71. Android has the DECODED result name here - the richer + // twin of the Apple raw byte, and the reason the reboot verdict was correct on this side - + // but for the clock nothing establishes what a result means, so this states no verdict + // either. It quotes the decoded name when there is one and the WHOLE frame in hex, which + // is what makes a wrong assumption visible instead of silently misleading. Log-only. + if (respCmd?.startsWith("SET_CLOCK") == true || respCmd?.startsWith("GET_CLOCK") == true) { + log( + "clock: $respCmd reply result=${result ?: "none"} " + + "frame=${frame.joinToString("") { "%02x".format(it) }}", + ) + } // 5/MG range-query gate: a GET_DATA_RANGE SUCCESS releases the history request // (PENDING precedes it; the 2s fail-open fallback covers a swallowed reply). (#78 fork) if (connectedFamily == DeviceFamily.WHOOP5 && backfilling && !historicalKickSent && @@ -6440,6 +8099,13 @@ class WhoopBleClient( runCatching { NoopPrefs.of(context).edit() .putLong("alarm.lastReportedEpoch", epoch) + // #1706: the strap this readback came from, and the bytes it came in. + // The raw frame is what separates a genuinely-stored stale alarm from a + // misdecode of a fixed response field, and the live log rolls long before + // a debug export is taken — a 2045 readback went unexplained for exactly + // that reason. + .putString("alarm.lastReportedDeviceId", deviceId) + .putString("alarm.lastReportedRaw", raw) .putLong("alarm.lastReportedAt", System.currentTimeMillis()) .apply() } @@ -6478,13 +8144,27 @@ class WhoopBleClient( // The 5/MG strap narrates its own sync engine here ("BLE: PullStats: Data: N…", // "RTC timestamp … is invalid") — gold for protocol research, so mirror it into the // strap log (capped; the ring buffer holds 2k lines). (#78 fork) - (parsed.parsed["console"] as? String)?.let { txt -> + (parsed.parsed["log"] as? String)?.let { txt -> log("strap: ${txt.take(300)}") } } "EVENT" -> { (parsed.parsed["event"] as? String)?.let { ev -> + // Test-Centre-gated census of EVERY event the strap pushes. Nothing logs event names + // today, so a strap log cannot answer "does this strap emit CHARGING_ON?" or + // "does it emit BATTERY_PACK_CONNECTED?" — the absence of a line proves nothing, + // because no line was ever written. That blind spot is what made the charging-pill + // bug and the cmd-151 dead end hard to reason about. Logs the name, whether the frame + // is a replayed offload record (so a historical event is never mistaken for a live + // one), and the 5/MG opaque payload hex — which is where a pack charge would live if + // any event carries one. Read-only; sends nothing and decodes nothing into state. + if (testCentre.active(com.noop.testcentre.TestDomain.CONNECTION)) { + val payHex = (parsed.parsed["event_payload_hex"] as? String) + ?.let { maskPackAddrInEventPayload(it, frame) } + log("[event] $ev${if (replayedOffload) " (replayed offload)" else ""}" + + (payHex?.let { " payload=$it" } ?: "")) + } // Event strings are "NAME(rawValue)", e.g. "WRIST_ON(9)" (see Schema.enumName). // Pure [isGestureEvent] so the gesture-vs-non-gesture routing is unit-testable (PR #577). val isGesture = isGestureEvent(ev) @@ -6536,6 +8216,79 @@ class WhoopBleClient( } else if (ev.startsWith("CHARGING_OFF")) { _state.update { s -> s.copy(charging = false) } } + // BATTERY_PACK_CONNECTED(21) / BATTERY_PACK_REMOVED(22) are defined in + // EventNumber and handled NOWHERE. On a 5/MG they fire reliably on every + // attach and detach, and they LEAD the 7/8 edges above, so handling them here + // is what makes the pill respond the moment a pack goes on or comes off. + // + // Measured on WHOOP MG fw 50.39.1.0 over two attach/detach cycles, with every + // pushed event logged: + // + // 01:40:51 BATTERY_PACK_CONNECTED(21) <- attach + // 01:40:51 CHARGING_ON(7) + // 01:41:06 CHARGING_OFF(8) + // 01:41:11 BATTERY_PACK_REMOVED(22) <- detach + // + // Both families fire; 21/22 are simply the ones nothing was listening to. The + // 7/8 branch above is kept exactly as it is -- this adds a second source for + // the same flag, it does not replace one. + // + // Same replayedOffload gate as the branch above, and it is load-bearing: the + // strap REPLAYS these edges during the next historical offload with identical + // payloads, so an accepted replay would switch the pill back on minutes after + // the pack was physically removed. + if (ev.startsWith("BATTERY_PACK_CONNECTED")) { + _state.update { s -> s.copy(charging = true) } + } else if (ev.startsWith("BATTERY_PACK_REMOVED")) { + // Removal clears the readout too: a detached pack must not leave a stale + // charge sitting on the card. + _state.update { s -> s.copy(charging = false, packSocPct = null) } + } + // The pack's own charge. The strap volunteers the full pack record — present + // flag, BT address, serial and SoC — in the UNCATALOGUED event 109 every couple + // of minutes while a pack is attached, so this needs no command, no + // send-allowlist entry and no polling. Because it REPEATS it is a level signal: + // unlike the old edge-driven charging flag it re-establishes itself after any + // reconnect and cannot go stale. + // + // Hardware-confirmed on WHOOP MG fw 50.39.1.0 (2026-09-01): three consecutive + // records, serial WBB5AP0000001, SoC 57.1% -> 56.9% -> 56.7% as the pack drained + // into the strap. That falling trend across a real physical process — not one + // plausible-looking value — is what establishes the SoC word as tenths of a + // percent. Gated by BatteryPackInfo.displayable below, so a wrong offset + // yields nothing rather than a fabricated reading. + // Matched on the event BYTE, not on the rendered label: an uncatalogued event + // renders as "0x6D(109)" only because nothing names it, so a future + // EventNumber entry would rename it and silently break a string match. + // A 5/MG event frame carries its event number at offset 10. + if (connectedFamily == DeviceFamily.WHOOP5 && frame.size > 10 && + (frame[10].toInt() and 0xFF) == com.noop.protocol.BatteryPackInfo.PACK_INFO_EVENT + ) { + com.noop.protocol.BatteryPackInfo.decodeEventFrame(frame)?.let { info -> + val soc = info.socPct + // [displayable] is #1303's gate, not a second copy of it: present, a + // charge, and inside 0..100. Two definitions of one rule is how the + // command path and the event path end up disagreeing about the same + // pack, which is the drift decodeRecord() exists to prevent. + if (info.displayable && soc != null) { + // SoC ONLY, deliberately (#1935). This event says a pack is + // ATTACHED and how full it is, never that current is flowing. + // It used to write charging=true as well, as the anti-staleness + // half for a missed attach edge — but it repeats every couple of + // minutes, so it outran the ~8 min BATTERY_LEVEL that corrects the + // flag from the strap's own GAUGE, and a flat or badly seated pack + // then read "charging" for the whole attachment instead of + // self-correcting. BATTERY_PACK_CONNECTED(21) still lights the pill + // on the attach edge, so the latency win survives; the gauge now + // gets the last word, which is what iOS already did. A missed + // attach edge costs at most one battery cadence of lag and is then + // RIGHT, instead of being fast and possibly wrong. + _state.update { s -> s.copy(packSocPct = soc) } + } else if (!info.present) { + _state.update { s -> s.copy(packSocPct = null) } + } + } + } } // PR #577: the strap fired its firmware smart alarm (STRAP_DRIVEN_ALARM_EXECUTED, // event 57) → re-arm the next day's instant (single absolute time, no recurrence). @@ -6598,6 +8351,7 @@ class WhoopBleClient( val flags = data[0].toInt() and 0xFF val hr16 = (flags and 0x01) != 0 val rrPresent = (flags and 0x10) != 0 + val contact = StandardHrContact.fromMeasurementFlags(flags) var idx = 1 val hr: Int @@ -6634,6 +8388,7 @@ class WhoopBleClient( // Skip the redundant it.copy() when HR is unchanged — StateFlow drops an equal state anyway, so // this only avoids the per-frame throwaway LiveState allocation (matches FrameRouter). The bonded // transition below stays UNCONDITIONAL: it must still fire once even while HR sits steady. + noteLiveHr() if (_state.value.heartRate != hr) _state.update { it.copy(heartRate = hr) } // EXPERIMENTAL WHOOP 5.0/MG: there is no confirmed-write bond for a 5/MG strap, so once // live HR actually streams over the standard profile we treat the link as established — @@ -6651,7 +8406,7 @@ class WhoopBleClient( // Record it continuously — independent of the realtime stream or which screen is open. // Port of BLEManager.parseStandardHR -> collector.ingestStandardHR(hr:rr:at:). - ingestStandardHr(hr, rr, (System.currentTimeMillis() / 1000L)) + ingestStandardHr(hr, rr, contact, (System.currentTimeMillis() / 1000L)) } /** The Test Centre gate, bound once to the app's single "noop_testcentre" prefs file. Lazily built so @@ -6721,18 +8476,42 @@ class WhoopBleClient( // outside the overnight window must not arm the stream from a stale precomputed [wantsRealtime]. val realtimeWantNow = screenWantsRealtime || continuousCaptureWantsNow() wantsRealtime = realtimeWantNow - if (realtimeWantNow) { realtimeArmed = true; send(CommandNumber.TOGGLE_REALTIME_HR, byteArrayOf(1)) } + if (realtimeWantNow) { realtimeArmed = true; realtimeArmedThisLink = true; send(CommandNumber.TOGGLE_REALTIME_HR, byteArrayOf(1)) } } // ==================================================================================== // MARK: Live-stream keep-alive (port of BLEManager.startKeepAlive / keepAliveFire) // ==================================================================================== + /** #1865: a live HR sample arrived — restart the stall clock and re-arm the one-shot recovery. */ + private fun noteLiveHr() { + lastLiveHrAtMs = System.currentTimeMillis() + realtimeReArmedSinceHr = false + } + + /** + * How long this family's live link may go silent before the app stops calling it healthy. + * + * Shared by the keep-alive's bounce decision and the Connect no-op below, deliberately: the no-op + * says "a reconnect would achieve nothing", and the only honest measure of that is the same one the + * watchdog uses to decide a reconnect IS needed. Two numbers here could disagree, and the way they + * would fail is a Connect button that declines to act on a link the watchdog has already given up on. + * + * Note it measures ANY inbound data, not live HR — which is why #1865 needed its own clock: a strap + * answering battery polls keeps this fuse from ever tripping while the HR stream is dead. + */ + private fun liveLinkStallFuseMs(): Long = + if (connectedFamily == DeviceFamily.WHOOP5) KEEPALIVE_STALL_5MG_EMPTY_MS else KEEPALIVE_STALL_MS + /** (Re)start the 30s keep-alive. Called from the connect handshake; cancelled in [reset]. */ private fun startKeepAlive() { handler.removeCallbacks(keepAliveRunnable) keepAliveTick = 0 lastDataAtMs = System.currentTimeMillis() // arm the watchdog from "now", not 1970 + // #1865: same reasoning for the HR clock — a fresh link has not missed anything yet, so the stall + // check must not fire on the first tick just because no sample has landed in the first 30 seconds. + lastLiveHrAtMs = System.currentTimeMillis() + realtimeReArmedSinceHr = false handler.postDelayed(keepAliveRunnable, KEEPALIVE_INTERVAL_MS) } @@ -6766,8 +8545,7 @@ class WhoopBleClient( // data — #580 mistakenly gated the wide fuse on `historyEmpty`, so a 5/MG that DID serve history // still thrashed on the 120s fuse (#1414). Widen to the whole 5/MG family; WHOOP 4 keeps 120s. // (`historyEmpty` still gates the battery-backfill interval — a separate concern, left as-is.) - val bounceFuse = if (connectedFamily == DeviceFamily.WHOOP5) - KEEPALIVE_STALL_5MG_EMPTY_MS else KEEPALIVE_STALL_MS + val bounceFuse = liveLinkStallFuseMs() if (silentMs > bounceFuse) { // Nothing for the fuse window — the live stream/link stalled. Bounce it: the auto-rescan on // disconnect re-bonds and resumes streaming (the automatic version of the manual fix). @@ -6821,17 +8599,57 @@ class WhoopBleClient( flushCaptureLog() } if (connectedFamily == DeviceFamily.WHOOP4) { - if (wantsRealtime) { realtimeArmed = true; send(CommandNumber.TOGGLE_REALTIME_HR, byteArrayOf(1)) } + if (wantsRealtime) { realtimeArmed = true; realtimeArmedThisLink = true; send(CommandNumber.TOGGLE_REALTIME_HR, byteArrayOf(1)) } // #battery: ~60 s normally, ~30 s while charging (see [batteryPollDue]). if (batteryPollDue(keepAliveTick, s.charging == true)) send(CommandNumber.GET_BATTERY_LEVEL) - } else if (connectedFamily == DeviceFamily.WHOOP5 && - batteryPollDue(keepAliveTick, s.charging == true) - ) { + } else if (connectedFamily == DeviceFamily.WHOOP5) { + // NO pack poll here (#1948). This used to send GET_BATTERY_PACK_INFO on the same + // cadence as the gauge, with a comment saying the pack "rides the SAME cadence". + // It never did: the 5/MG send allowlist admits opcode 151 ONLY while a user-initiated + // probe is in flight, so every one of these was refused before it left the app. A + // capture caught 40 of them in 40 minutes, one wasted call and one skip line a minute. + // + // Nothing is lost by not asking. The pack's charge arrives on the pushed pack-info + // event (109), which since #1945 is its only writer, and the STRAP's own percent comes + // from the 0x2A19 read below, not from a command. The probe path is untouched, and + // remains how the hardware question ("does a 5/MG answer 151 at all?") gets asked. + // #1865: re-arm a LAPSED realtime stream. The WHOOP4 branch above re-sends + // TOGGLE_REALTIME_HR every tick precisely because "the firmware lets the realtime HR + // stream lapse if it isn't re-armed" — a 5/MG got none of that, on the reasoning that it + // "rejects WHOOP4-framed commands". That premise no longer holds for this command: + // `reconcileRealtime` above sends the toggle to BOTH families because send() routes the + // 5/MG one with puffin framing. + // + // Nothing else recovers it. reconcileRealtime is edge-triggered, so while we believe the + // stream is armed it sends nothing; the one-shot re-subscribe fixes a dropped CCCD, not a + // lapsed stream; and the stall bounce keys on [lastDataAtMs], which battery polls and + // history offload keep fresh. Reported exactly that way: bonded, worn, "history synced + // just now", and no bpm — every other signal healthy, which is what kept the one path + // that could have helped from firing. + // + // Gated on an HR-SPECIFIC stall rather than sent every tick: a healthy 5/MG stream costs + // nothing, and the 0x2A37 profile legitimately lulls at rest (why #1414 widened the bounce + // fuse to 10 min), so the threshold sits well past a normal lull. One re-arm per stall + // episode, cleared when a sample lands. + val hrSilentMs = System.currentTimeMillis() - lastLiveHrAtMs + if (shouldReArmLapsedRealtime( + wantsRealtime = wantsRealtime, + realtimeArmed = realtimeArmed, + reArmedSinceHr = realtimeReArmedSinceHr, + hrSilentMs = hrSilentMs, + ) + ) { + realtimeReArmedSinceHr = true + log("No live HR for ${hrSilentMs / 1000}s while armed — re-arming the 5/MG realtime stream (#1865)") + send(CommandNumber.TOGGLE_REALTIME_HR, byteArrayOf(1)) + } + if (batteryPollDue(keepAliveTick, s.charging == true)) { // 5/MG battery comes only from a 0x2A19 read and the strap sends no unsolicited battery // notification, so poll it here (about every 60s) rather than only while the Live screen // is open. The ring then stays current on any screen without a manual sync, and the read // keeps the link warm. - refreshBattery() + refreshBattery() + } } } } @@ -6947,6 +8765,7 @@ class WhoopBleClient( if (want == realtimeArmed) return // no edge — nothing to send if (connectedFamily != DeviceFamily.WHOOP4 && !_state.value.bonded) return // can't reach the strap yet realtimeArmed = want + if (want) realtimeArmedThisLink = true // #1809: latch the per-link fact // Both families arm/disarm via TOGGLE_REALTIME_HR; send() frames it correctly per family (puffin // for 5/MG). A screen re-entry blanks its own smoothing window in the view-model, not here. send(CommandNumber.TOGGLE_REALTIME_HR, byteArrayOf(if (want) 1.toByte() else 0.toByte())) @@ -7473,6 +9292,7 @@ class WhoopBleClient( // cadence, not this drop path, so a persistently-busy stack retries once per tick, never in a loop. if (shouldReArmRealtimeAfterDrop(item.cmd)) { realtimeArmed = !realtimeArmed + if (realtimeArmed) realtimeArmedThisLink = true // #1809: per-link latch log("realtime toggle dropped — reconciling on the next keep-alive tick (#312)") } writeRetries = 0 @@ -7547,7 +9367,19 @@ class WhoopBleClient( val ok = safeGatt("writeClientHello") { ops.writeCharacteristicCompat(ch, hello, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) } - if (!ok) { + if (ok) { + // Claimed only once the stack ACCEPTED the write: setting it before the call left a window + // where a concurrent reader saw a hello that had not gone out. + // + // The deferral run is deliberately NOT cleared here. A written hello is not a working + // handshake - on a strap answering SMP 0x05 the write is never acked and the bond watchdog + // bounces the link seconds later. Clearing on the WRITE reset the run on every bounce, so the + // next connect deferred again and the strap alternated defer/write/bounce forever, which is + // exactly the loop #1642 removed. The run records ATTEMPTS and only a genuine bond ends it; + // the #1635 suppression latch is what bounds the attempts and settles the link into the + // "Live HR, not fully paired" state the app already models. + helloWrittenThisLink = true + } else { writeInFlight = false log("CLIENT_HELLO write rejected by stack") clientHelloWriteAtMs = 0L // never went out; no callback is owed @@ -7598,12 +9430,65 @@ class WhoopBleClient( log(bondStateAtConnectLine(g.device.bondState, g.device.address), com.noop.testcentre.TestDomain.CONNECTION) } + // #1635: the unbonded offload probe replaces the handshake on this connect, and it must + // run BEFORE the pairing request as well as before the hello — its question needs a link + // with neither on it. Placed here rather than beside the hello decision because the + // deferral branch below returns early, and a probe scheduled after that never runs. + if (unbondedProbeSupersedesHandshake( + optedIn = puffinExperiment.unbondedOffload, + isWhoop5 = true, + appLevelBonded = didBond, + userInitiated = helloRetryRequested, + // Same retirement the probe applies to itself — read here rather than assumed, so + // the skip stops the moment the thing it serves does. The two inputs address the + // same strap: `lastDevice` is set at connect, long before this runs post-discovery, + // and `beginUnbondedOffloadProbe` already pairs these two sources the same way. + probeRetired = unbondedProbeRetired( + previouslyRefused = unbondedOffloadPreviouslyRefused(g.device.address), + silentLinksSoFar = unbondedProbeSilentLinks, + ), + ) + ) { + log(unbondedProbeSupersedesLine(explicitBondOptedIn = puffinExperiment.explicitBond)) + // The watchdog was armed at discovery and bounces the link whenever didBond is false, + // which skipping the handshake guarantees. Same reasoning as the suppression path: with + // no handshake outstanding there is nothing left for it to time out, and leaving it + // armed would tear down the stable link the probe needs. + cancelBondWatchdog() + scheduleUnbondedDisRead() + scheduleUnbondedOffloadProbe() + return + } + // Read ONCE, here, above both decisions that consult it. The pairing request is made + // before the hello is considered, so a latch read only at the hello would leave the + // request unable to see the verdict it is supposed to respect. + // + // This is only safe because NOTHING between here and the hello writes the latch, and that + // became true when the pairing request stopped clearing it. Anything reintroducing a write + // in that window silently hands the hello a stale verdict — so put the write before this + // read, or take a second one, rather than leaving the two decisions disagreeing. + val suppressed = runCatching { + com.noop.ui.NoopPrefs.helloSuppressed(context, g.device.address) + }.getOrDefault(false) if (shouldRequestExplicitBond( optedIn = puffinExperiment.explicitBond, isWhoop5 = true, alreadyBondedAtOsLevel = osBonded, appLevelBonded = didBond, alreadyRequestedThisLink = explicitBondRequestedThisLink, + // #1635: deliberately the raw latch, with no `userInitiated` escape hatch beside + // the hello's. A Connect used to re-fire createBond only as a side effect of + // clearing the latch; now that the latch survives the tap, the pairing request + // stays retired and the tap's one fresh attempt is the HELLO. That is the right + // half to keep — the hello is the implicit pairing trigger, so a strap put into + // pairing mode still has a route, while each createBond this strap declines + // raises a system "Pairing rejected" notice the user never asked for. + // + // Do NOT "fix" this by passing `suppressed && !helloRetryRequested`. The deferral + // branch below returns BEFORE the hello consumes that flag, so a re-armed request + // would defer the hello, leave the retry set, and re-arm itself on the next link. + // A full pairing retry is Forget + re-add, which clears the latch outright. + bondGivenUpForDevice = suppressed, ) ) { explicitBondRequestedThisLink = true @@ -7615,15 +9500,50 @@ class WhoopBleClient( runCatching { g.device.createBond() }.fold( onSuccess = { initiated -> log(explicitBondRequestLine(initiated, where)) - // Asking for a pairing voids the previous verdict: suppression latched because - // the write never completed on an UNENCRYPTED link, and that premise is exactly - // what this is trying to change. Cleared ONCE, here, rather than re-armed by - // "an OS pairing exists" — that condition never goes away, so it would bypass - // the latch on every connect and restore the unbounded loop for good. + // The suppression latch is NOT cleared here, and this is the correction to the + // comment that used to stand in its place. That comment reasoned the clear was + // safe because it happened "ONCE, here", as opposed to being re-armed by "an OS + // pairing exists" — a condition that never goes away. It guarded the wrong + // recurrence. [shouldRequestExplicitBond] is bounded only by + // `alreadyRequestedThisLink`, a per-LINK flag, and on a strap that answers SMP + // "Pairing Not Supported" neither bond condition ever becomes true — so + // createBond fires on EVERY connect, and clearing here fired on every connect + // with it. The 31 Aug capture: 18 pairing requests and 18 hellos — one of each + // on every link, which is the pairing [helloDeferredByExplicitBond] forbids — + // with the latch written once at 17:32:48, the "CLIENT_HELLO suppressed" line + // zero times, and 13 of those hellos landing after the give-up, each dropping + // the link ~4.8s in. + // + // It could not recover, either: BondRefusalGiveUp.recordRefusal() reports the + // crossing exactly once and stays gaveUp until reset, so the latch is written + // once per session. Anything recurring that clears it does not cost one link — + // it costs the latch permanently. + // + // What replaces it is the explicit Connect, which grants ONE fresh handshake + // attempt through [helloRetryRequested] — precisely what the epitaph tells the + // user to do, and pinned by HelloSuppressionTest's "suppression is never + // permanent". Note it is the retry flag that carries this, NOT a clear of the + // latch: clearPairingHintForUserConnect() deliberately leaves the latch set so + // the automatic reconnects AFTER that attempt stay suppressed. Be exact about + // the alternative: the encrypted-bond clear at clearPairingHint() lives inside + // the hello WRITE-COMPLETION callback, so a suppressed hello cannot reach it. + // Claiming it as an automatic route would be the same overclaim the give-up + // line just had to be corrected for. + // + // That costs nothing real. The latch is only set after five consecutive + // refusals, so "latched, and pairing now works" needs something to have + // CHANGED — new firmware, the strap put into pairing mode (@Zebsi235's MG) — + // and a user who has changed something taps Connect. Voiding the verdict on + // the mere REQUEST voided it on a hope this strap never fulfils, every eleven + // seconds. The pairing experiment itself is untouched in the window that + // matters: createBond runs on every link until the latch is set, and it no + // longer drags the hello back with it, which is what + // [helloDeferredByExplicitBond] says must never share a link. Be precise + // about after: `bondGivenUpForDevice = suppressed` retires the request for as + // long as the latch stands, and since the latch now survives a Connect, the + // way back is a genuine bond or Forget + re-add — not a tap. The field log + // read as "createBond on every link" only because every tap wiped the latch. if (initiated) { - runCatching { - com.noop.ui.NoopPrefs.setHelloSuppressed(context, g.device.address, false) - } // Ask the device directly rather than waiting on our own broadcast // receiver, which is a suspect in exactly the silence being investigated. handler.postDelayed({ @@ -7635,8 +9555,63 @@ class WhoopBleClient( }, onFailure = { log(explicitBondThrewLine(it.javaClass.simpleName, where)) }, ) + } else if (suppressed && !explicitBondGiveUpLogged && shouldRequestExplicitBond( + optedIn = puffinExperiment.explicitBond, + isWhoop5 = true, + alreadyBondedAtOsLevel = osBonded, + appLevelBonded = didBond, + alreadyRequestedThisLink = explicitBondRequestedThisLink, + // The question this branch is actually asking: would we be asking to pair if the + // give-up were not latched? Restating the gate's other conditions here instead + // would drift the moment a sixth one is added — and drift silently, into a line + // that blames the give-up for a decision some other check made. + bondGivenUpForDevice = false, + ) + ) { + // Say it, once. The switch is on and doing nothing, which without a line looks exactly + // like the switch being broken — and the user has just been reading Android's pairing + // rejections, so they are owed the app's side of it. + explicitBondGiveUpLogged = true + log(explicitBondGivenUpLine()) + } + // #1635: read once, before the deferral gate — the override has to reach BOTH, or a + // deferred hello returns early and the switch below is never evaluated at all. + val overrideOptedIn = runCatching { + PuffinExperiment.from(context).helloDespiteBondRefusal + }.getOrDefault(false) + // Flipping the switch off and back on is the user asking for another try. The counter + // outlives the toggle, so without this a re-enabled override does nothing at all — and + // says nothing, the give-up line having already latched. + if (helloOverrideBudgetRearms(overrideOptedIn, helloOverrideOptInSeen)) { + helloOverrideAttempts = 0 + helloOverrideExhaustedLogged = false } - if (explicitBondDefersHello(explicitBondRequestedThisLink)) { + helloOverrideOptInSeen = overrideOptedIn + // Spent budget makes the override inert rather than merely quiet: it must stop reaching the + // deferral bypass too, or the link keeps being taken down by a hello nobody will answer. + val helloOverride = helloOverrideActive(overrideOptedIn, helloOverrideAttempts) + if (overrideOptedIn && !helloOverride && !helloOverrideExhaustedLogged) { + helloOverrideExhaustedLogged = true + log(helloOverrideExhaustedLine(helloOverrideAttempts)) + } + // priorDeferrals ends the permanent cycle: the pairing got its one connect and did not + // bond, so this one writes the hello rather than deferring to a "next connect" that never + // resolves on a strap answering SMP 0x05. + if (explicitBondDefersHello(explicitBondRequestedThisLink, helloOverride = helloOverride, + priorDeferrals = helloDeferredRun())) { + // This branch used to return without a word, so the hello's absence was visible only + // as a line that never appeared — and the deferral reads identically on the first + // connect and the fiftieth. The count is what separates them. + val deferralRun = helloDeferredRun() + 1 + setHelloDeferredRun(deferralRun) + val fullGuidance = !helloDeferredGuidanceLogged + if (deferralRun >= 2) helloDeferredGuidanceLogged = true + log(helloDeferredByExplicitBondLine( + consecutive = deferralRun, + overrideOptedIn = overrideOptedIn, + overrideAttempts = helloOverrideAttempts, + full = fullGuidance, + )) // Same trap as the suppression path below, and worse here. The watchdog was armed at // discovery and bounces the link whenever didBond is false — which deferring the hello // guarantees. Tearing the link down while an OS pairing is in flight is the single @@ -7647,6 +9622,7 @@ class WhoopBleClient( // path uses — and without this the DIS read never runs for anyone with the pairing // experiment on, which is precisely who is testing. scheduleUnbondedDisRead() + scheduleUnbondedOffloadProbe() return } @@ -7658,10 +9634,21 @@ class WhoopBleClient( // restart the loop the suppression exists to end. val userAsked = helloRetryRequested helloRetryRequested = false - val suppressed = runCatching { - com.noop.ui.NoopPrefs.helloSuppressed(context, g.device.address) - }.getOrDefault(false) - if (shouldSendClientHello(suppressed, userInitiated = userAsked)) { + if (shouldSendClientHello(suppressed, userInitiated = userAsked, overrideSuppression = helloOverride)) { + // Charge the budget only when the override is what put this hello on the wire: a + // strap that is neither suppressed nor deferring would have sent it anyway, and + // counting those would retire the experiment without ever having run it. + if (helloOverride && !userAsked && (suppressed || explicitBondRequestedThisLink)) { + helloOverrideAttempts++ + } + if (suppressed && helloOverride && !userAsked) { + // Say WHY a suppressed strap is getting a hello anyway, or the next reader sees the + // latch set and a hello on the wire and has to guess which of them is broken. + log("WHOOP 5/MG: CLIENT_HELLO sent DESPITE the suppression latch — \"send hello" + + " despite bond refusal\" is on. The strap refuses SMP pairing (Pairing Not" + + " Supported), so this is the only handshake left to try; expect the ~4.8s drop" + + " loop to return if it still goes unanswered (#1635, experimental).") + } writeClientHello(g, cmd) } else { // The watchdog was armed at discovery, before this decision could be made, and it @@ -7669,9 +9656,30 @@ class WhoopBleClient( // didBond stays false, so leaving it armed would just move the drop from ~4.8s out to // the 7s window — the same loop, slower. There is no handshake left to time out. cancelBondWatchdog() + // #1635: says what happened and what has actually worked, not "try again". This + // strap refuses the handshake, so a retry is the one thing that cannot help — and + // suggesting it invites the hammering this suppression exists to stop. Mirrors the + // user-facing hint, which lost the same ending. log("WHOOP 5/MG: CLIENT_HELLO suppressed for this strap — it was never acknowledged and" + - " the write is what drops the link. Staying on live HR (not fully paired); press" + - " Connect to try the handshake again (#1635).") + " the write is what drops the link. Staying on live HR (not fully paired). Some straps" + + " have paired again after being put in pairing mode (tap until the LEDs flash blue)" + + " (#1635).") + // #221: republish the pairing hint, which the Devices card's "Connected · not paired" + // pill reads. It is otherwise written only where a refusal FRESHLY crosses the give-up, + // and the latch that records the give-up outlives the process while the hint did not — + // so every launch after the one that gave up showed a green "Active · Live" beside a + // feature list naming Sleep, Strain and HRV, on a strap that has never banked a row. + // Placed HERE, next to the line reporting the same fact, because Swift already does + // exactly this (BLEManager.swift, the matching suppression branch): the gap was + // one-sided, and this closes it rather than inventing a second placement. + // + // Assigned, not seeded. A first attempt kept any hint already published, on the + // reasoning that a live observation beats a remembered one — but the only hint that can + // be present here came from a PREVIOUS link, since refusals are detected on teardown + // and this runs during setup. A stale hint about a condition that may no longer hold + // is not the fresher fact; the state of the link in hand is. Swift assigns for the + // same reason, and mirrored code diverging quietly is worse than either choice. + _state.update { it.copy(pairingHint = BondRefusalGiveUp.helloSuppressedHint()) } // The unbonded DIS attempt rides HERE, on the suppressed link, and nowhere else. This // is the only 5/MG state known to be stable: the handshake is off, the watchdog is // cancelled, and the link holds. During the reconnect loop it would have ~4.8s and @@ -7679,6 +9687,7 @@ class WhoopBleClient( // both harder to read. If the read is what tears a stable link down, that is // unambiguous — and it latches, so it costs one link and not a loop. scheduleUnbondedDisRead() + scheduleUnbondedOffloadProbe() } } } @@ -7695,6 +9704,18 @@ class WhoopBleClient( if (cccdInFlight) return val ch = cccdQueue.poll() if (ch == null) { + // #1635: the unbonded probe's own drain. BEFORE the bonded branch because that one requires + // didBond, which the probe deliberately never sets — and this must not fall through to + // startSession, which would re-enter the hello decision on a link that already made it. + if (connectedFamily == DeviceFamily.WHOOP5 && unbondedProbeSubscribing) { + unbondedProbeSubscribing = false + if (unbondedProbeSubscribed == 0) { + log(unbondedProbeNoSubscriptionsLine(WHOOP5_NOTIFY_CHARS.size)) + return + } + askUnbondedProbeQuestion() + return + } // 5/MG handshake tail: after the PUFFIN notify chars are subscribed (the post-CLIENT_HELLO // drain — didBond is true by then), clock the strap and only then kick the offload. An // un-clocked WHOOP 5 discards sensor data ("RTC timestamp … is invalid; not saving data to @@ -7703,6 +9724,7 @@ class WhoopBleClient( // once-per-connection (keep-alive resubscribes also land here). (#78 fork, hardware-proven) if (connectedFamily == DeviceFamily.WHOOP5 && didBond && !connectHandshakeDone) { connectHandshakeDone = true + _state.update { it.copy(historyReady = true) } noteRebootReconnectIfNeeded() send(CommandNumber.SET_CLOCK, setClockPayload(), withResponse = true) send(CommandNumber.GET_CLOCK, byteArrayOf(), withResponse = true) @@ -7712,7 +9734,11 @@ class WhoopBleClient( // #520: read the DIS identity on the same post-handshake schedule, staggered after the // battery read so the two do not contend for the serialized GATT queue. handler.postDelayed({ readDisIdentity() }, BATTERY_ON_CONNECT_DELAY_MS * 2) - log("WHOOP 5/MG: clock synced (set/get) — strap can persist history now") + // #1823: say what was SENT, not what resulted. This read "clock synced — strap can + // persist history now", logged before any reply existed, so a log could assert the clock + // was set while the Devices readout said 1970/71. The strap's own answer now arrives as + // the "clock: …" line above. Twin of the Apple wording. + log("WHOOP 5/MG: SET_CLOCK + GET_CLOCK sent — awaiting the strap's answer") if (!backfillStarted) { backfillStarted = true handler.postDelayed({ requestSync(BackfillTrigger.CONNECT) }, INITIAL_BACKFILL_DELAY_MS) @@ -7814,46 +9840,133 @@ class WhoopBleClient( val now = (System.currentTimeMillis() / 1000L).toInt() val parsed = frames.map { it.second } // #47: the dispatcher already decoded these — don't re-parse val newestRealtimeTs = parsed.asSequence() - .filter { it.ok && it.crcOk != false && it.typeName == "REALTIME_DATA" } + // The FULL verdict in one condition. `crcOk != false` used to sit beside it and was the + // loose half: it passed a frame whose CRC32 could not be computed at all, and said nothing + // about the header checksum or the declared length. `ok` now covers all three, so a frame + // with a forged timestamp cannot become the anchor this whole batch of live HR is stamped + // against. + .filter { it.ok && it.typeName == "REALTIME_DATA" } .mapNotNull { (it.parsed["timestamp"] as? Number)?.toInt() } .maxOrNull() ?: now val streams: Streams = extractStreams(parsed, deviceClockRef = newestRealtimeTs, wallClockRef = now) val batch = StreamPersistence.toBatch(streams) + // #1118: the SECOND live transport. The standard 0x2A37 path above stamps a beat at the second + // it arrived; this one stamps it from the strap's own record clock. The same beat reaching both + // lands on two different seconds, which no same-second de-dup can collapse — the signature every + // affected night prints as `crossSecondOverCount`. + if (batch.rr.isNotEmpty()) { + if (com.noop.analytics.RrEmissionStats.shouldEmitLiveCensus(lastRealtimeRrCensusSec, now)) { + lastRealtimeRrCensusSec = now + val census = com.noop.analytics.RrEmissionStats.compute(batch.rr.map { it.ts.toInt() to it.rrMs }) + log(com.noop.analytics.RrEmissionStats.logLine("live-realtime", batch.rr.size, null, census)) + } + } if (!batch.isEmpty) { try { - repository.insert(batch, deviceId) + addBankedLive(repository.insert(batch, deviceId)) + liveInsertFailuresRealtime.set(0) } catch (t: Throwable) { // Re-buffer at the front so these frames retry on the next cadence (port of Collector). synchronized(collectorLock) { liveBuffer.addAll(0, frames) } + // The #1118 SECOND transport, silent for the same reason the standard path was: the + // census above reports what was OFFERED, so a store rejecting everything still reads + // like a healthy stream. + val runLength = liveInsertFailuresRealtime.incrementAndGet() + val nowMs = System.currentTimeMillis() + if (shouldEmitLiveInsertFailure(lastRealtimeInsertFailureLogMs, nowMs)) { + lastRealtimeInsertFailureLogMs = nowMs + log(liveInsertFailedLine( + transport = "live-realtime", + throwableName = t.javaClass.simpleName, + message = t.message, + hrFrames = batch.hr.size, + rrFrames = batch.rr.size, + consecutiveFailures = runLength, + )) + } } } } + /** #1118: last emit of each LIVE R-R census line, unix seconds; 0 = never. See + * [com.noop.analytics.RrEmissionStats.shouldEmitLiveCensus] for why these are rate-limited. + * + * Deliberately UNSYNCHRONIZED, unlike the buffers they sit beside. The two flushes can run + * concurrently on the io scope, but a 32-bit write is atomic on the JVM so the worst outcome of a + * stale read is one duplicate diagnostic line — cheaper than taking `collectorLock` on a path whose + * only job is to describe itself. (The Swift twin is @MainActor-isolated and gets the guarantee for + * free; the asymmetry is intentional, not an oversight.) + * + * Lifetime, which DIVERGES from the Swift twin and is worth knowing before reading a log: + * `WhoopBleClient` is the process-wide lazy singleton on `NoopApplication`, and a device switch + * mutates `deviceId` via [setActiveDeviceId] rather than rebuilding the client — so these never + * reset for the life of the process, and the 15-minute cadence holds across reconnects. The Swift + * side keeps them on `Collector`, which `BLEManager.bootstrapStore()` REBUILDS (a store rebuild + * after unlock, among other paths), so an iOS log can carry an extra line after one of those. + * Harmless either way — it is a rate-limit on a diagnostic, not a measurement — but a reader + * comparing two logs should not have to work out why one has more lines than the other. */ + private var lastStdRrCensusSec: Int = 0 + private var lastRealtimeRrCensusSec: Int = 0 + /** * Buffer one standard 0x2A37 reading (carries a wall-clock ts directly, no clock ref needed). * Auto-flushes ~every 30 readings. Port of `Collector.ingestStandardHR`. */ - private fun ingestStandardHr(hr: Int, rr: List, ts: Long) { + private fun ingestStandardHr(hr: Int, rr: List, contact: StandardHrContact, ts: Long) { val shouldFlush = synchronized(collectorLock) { if (hr in 30..220) stdHr.add(HrRow(ts, hr)) for (r in rr) if (r in 250..3000) stdRr.add(RrRow(ts, r)) - stdHr.size + stdRr.size >= 30 + stdContact.add(StandardHrMapping.contactEvent(ts, contact)) + standardHrBufferReachedFlushThreshold(stdHr.size, stdRr.size, stdContact.size) } if (shouldFlush) ioScope.launch { flushStandardHr() } } /** Persist the buffered standard HR/RR. Re-buffers on failure. Port of `Collector.flushStandardHR`. */ private suspend fun flushStandardHr() { - val (hr, rr) = synchronized(collectorLock) { - if (stdHr.isEmpty() && stdRr.isEmpty()) return + val (hr, rr, contact) = synchronized(collectorLock) { + if (stdHr.isEmpty() && stdRr.isEmpty() && stdContact.isEmpty()) return val h = ArrayList(stdHr); val r = ArrayList(stdRr) - stdHr.clear(); stdRr.clear() - h to r + val c = ArrayList(stdContact) + stdHr.clear(); stdRr.clear(); stdContact.clear() + Triple(h, r, c) + } + // #1118: census this batch BEFORE it is stored, exactly as the historical path does, so a + // strap log carries one `ratioRep` per transport. If each transport reports ~1.0 while the + // stored night reads 2.77, the over-count is the UNION of the transports and no single + // decoder is at fault — which is the question this instrumentation exists to settle. + if (rr.isNotEmpty()) { + val nowSec = (System.currentTimeMillis() / 1000L).toInt() + if (com.noop.analytics.RrEmissionStats.shouldEmitLiveCensus(lastStdRrCensusSec, nowSec)) { + lastStdRrCensusSec = nowSec + val census = com.noop.analytics.RrEmissionStats.compute(rr.map { it.ts.toInt() to it.rrMs }) + // `inserted` is NULL, not echoed from `offered`: the store's conflict key decides that + // and this census runs before the insert. The line renders `inserted=n/a`. + log(com.noop.analytics.RrEmissionStats.logLine("live-standard", rr.size, null, census)) + } } try { - repository.insert(StreamBatch(hr = hr, rr = rr), deviceId) + addBankedLive(repository.insert(StreamBatch(hr = hr, rr = rr, events = contact), deviceId)) + liveInsertFailuresStd.set(0) } catch (t: Throwable) { - synchronized(collectorLock) { stdHr.addAll(0, hr); stdRr.addAll(0, rr) } + synchronized(collectorLock) { + stdHr.addAll(0, hr); stdRr.addAll(0, rr); stdContact.addAll(0, contact) + } + // Swallowing this made the instrumentation above read like success: a store failing every + // insert produced a log full of `rr emit ... offered=N` and no sign that none of it landed. + val runLength = liveInsertFailuresStd.incrementAndGet() + val nowMs = System.currentTimeMillis() + if (shouldEmitLiveInsertFailure(lastStdInsertFailureLogMs, nowMs)) { + lastStdInsertFailureLogMs = nowMs + log(liveInsertFailedLine( + transport = "live-standard", + throwableName = t.javaClass.simpleName, + message = t.message, + hrFrames = hr.size, + rrFrames = rr.size, + consecutiveFailures = runLength, + )) + } } } @@ -7871,7 +9984,27 @@ class WhoopBleClient( */ private fun beginBackfill() { if (!connectHandshakeDone) { - log("Backfill: deferred — connect handshake not done yet") + backfillDeferralsThisLink += 1 + // familyEstablished is read BEFORE connectedFamily on purpose: the happens-before it carries + // is what makes the family read safe, and connectedFamily otherwise holds a default or the + // previous link's value. Same ordering rule as batterySource(familyEstablished, family). + val established = familyEstablished + log(backfillDeferredLine( + family = if (established) connectedFamily.name else null, + didBond = didBond, + helloEverWrittenThisLink = helloWrittenThisLink, + explicitBondRequestedThisLink = explicitBondRequestedThisLink, + deferralsThisLink = backfillDeferralsThisLink, + msSinceConnect = if (connectedAtMs > 0L) System.currentTimeMillis() - connectedAtMs else -1L, + // #1802: name the unbonded-offload probe when the structural-unreachable case applies. + // The probe is the one action that exists for this state, and without this hint the + // diagnostic reads as hopeless when it is not. + unbondedProbeOptedIn = puffinExperiment.unbondedOffload, + unbondedProbeRetired = unbondedProbeRetired( + previouslyRefused = unbondedOffloadPreviouslyRefused(lastDeviceAddress), + silentLinksSoFar = unbondedProbeSilentLinks, + ), + )) return } if (backfilling) return @@ -7980,10 +10113,21 @@ class WhoopBleClient( clockUntrusted = clockUntrusted, ) ) { + // #1683: the empty streak only stretches the floor for the AUTOMATIC triggers (PERIODIC and + // STRAP). Naming it on a CONNECT/FOREGROUND skip - which uses the flat event floor and is not + // backed off at all - reads as though the streak caused the skip. It misled a reader of a real + // capture, so the line now names the streak only where the streak is actually doing something. + val streakGatesThisTrigger = + trigger == BackfillTrigger.PERIODIC || trigger == BackfillTrigger.STRAP log( - "Backfill: skipped ($trigger) - policy floor not met " + - "(empty streak ${emptySyncTracker.consecutiveEmptySyncs}" + - "${if (clockUntrusted) ", clock future-dated" else ""})", + "Backfill: skipped ($trigger) - policy floor not met" + + // BOTH the streak and the future-dated clock gate PERIODIC/STRAP only; CONNECT and + // FOREGROUND use the flat event floor. Naming either on those triggers reads as a + // cause, which is how this line misled a reader of a real capture. + (if (streakGatesThisTrigger) + " (empty streak ${emptySyncTracker.consecutiveEmptySyncs}" + + "${if (clockUntrusted) ", clock future-dated" else ""})" + else ""), ) return } @@ -8196,6 +10340,18 @@ class WhoopBleClient( bankedSensorRecords = bankedSensorRecords, consoleOnly = bankedNothingRaw, ) else false + // #1683: the strap's newest stored record, ONLY when it is stale enough to be worth naming. + // Declared out here because both the log line inside the block below and the user-facing banner + // further down consume it, and they must not disagree about whether the strap is stale. + // WHOOP4 only, explicitly, on BOTH platforms. The underlying field is not populated alike: Swift + // gates it on `feedsSync` (#695 - WHOOP4 today, the 5/MG path deliberately leaves it unset), while + // this side sets it for any family that answers GET_DATA_RANGE. Reading it without a gate would + // let a 5/MG show the dated wording here and not on iOS. Gating the READ on both keeps the two in + // step whichever way that pre-existing difference is settled later, and a 5/MG that cannot offload + // has no business being told it stopped saving to flash. + val staleNewestSeen: Long? = strapNewestTs + ?.takeIf { connectedFamily == DeviceFamily.WHOOP4 } + ?.takeIf { Backfiller.isStaleNewestRecord(it, nowSec) } if (bankedNothing) { val detail = if (consoleChunksThisSession >= 3) "console-only across $consoleChunksThisSession chunks" @@ -8204,6 +10360,11 @@ class WhoopBleClient( "Backfill: completed but the strap banked no sensor history ($detail); " + "consecutive empty syncs = ${emptySyncTracker.consecutiveEmptySyncs}.", ) + // #1683: say HOW OLD the strap's newest stored record is. Without this the line above reads + // identically for a strap that is caught up and one that stopped banking three weeks ago - + // NOOP knows the difference (GET_DATA_RANGE gave it) and simply never said so, which is why + // #1541 stayed open and unactionable. Rare-event evidence, so always-on. + staleNewestSeen?.let { log(Backfiller.staleRecordLine(it, nowSec)) } } // #battery: maintain the empty-offload backoff counter (see [consecutiveEmptyOffloads]). A 0-row // session — clean HISTORY_COMPLETE-empty OR an idle-timeout STALL — means there was nothing new to @@ -8226,15 +10387,23 @@ class WhoopBleClient( } // PR #556 reimpl: persist the HISTORY_COMPLETE instant so "Last synced N ago" survives a BLE-client // recreation / process restart and stops reverting to "Never". - if (reason == "HISTORY_COMPLETE") NoopPrefs.setLastSyncAt(context, nowSec) + // Stamped against the strap that actually completed this offload, never globally. The old single + // key reported one strap's sync on another's screen — a 5/MG with zero banked rows reading + // "Last sync: 4d ago" from its paired 4.0, which is what sent this whole investigation after a + // regression that never existed. + if (reason == "HISTORY_COMPLETE") NoopPrefs.setLastSyncAtFor(context, lastDeviceAddress, nowSec) // #57 debug: write-health signal for the export. "Last sync" fires even on an empty/failed offload, // so it can't distinguish "0 rows because the strap was empty" from "0 rows because writes FAILED". // Record the last time rows actually landed, and the last time an offload STALLED on a persist // failure (the closed-DB-after-restore class) — so a future "sync stuck at 0" report is decidable. runCatching { + // Per strap, for the same reason the last-sync stamp above is: these two lines sat together + // in the capture, both global, both reporting the 4.0's activity on the 5/MG's screen. val p = NoopPrefs.of(context).edit() - if (backfiller.sessionRowsPersisted > 0) p.putLong("sync.lastWriteOkAt", nowSec) - if (backfiller.persistStalled) p.putLong("sync.lastWriteStalledAt", nowSec) + val okKey = writeHealthPrefKey(lastDeviceAddress, "lastWriteOkAt") + val stalledKey = writeHealthPrefKey(lastDeviceAddress, "lastWriteStalledAt") + if (backfiller.sessionRowsPersisted > 0 && okKey != null) p.putLong(okKey, nowSec) + if (backfiller.persistStalled && stalledKey != null) p.putLong(stalledKey, nowSec) p.apply() } // #580: a WHOOP 5/MG whose firmware serves no history offload (acks SEND_HISTORICAL_DATA but emits @@ -8269,8 +10438,18 @@ class WhoopBleClient( // the two platforms never disagree on which banner a given sync shows. lastSyncError = when { bankedNothing && sustainedEmpty -> - "Synced, but your strap had no stored history to hand over - only its diagnostic output. This usually means its clock has lost sync, so it isn't saving data to flash. Fully charge it to 100%, then reconnect, and it should start banking again." - bankedNothing -> null // banked nothing but not yet sustained — stay silent (matches Swift) + // #1683: when the strap's own newest record dates the silence, SAY it. The + // generic copy omits that and promises a recovery the charge advice has already + // been retried for every session. + // #1754: the generic "clock lost sync" copy is only correct when the strap + // reported trim=0xFFFFFFFF (no valid flash cursor). A strap with a valid, + // advancing flash cursor that banks no sensor records has a different problem + // - the sensor front-end or power, not the clock - and telling the user to + // charge it sends them away from the real cause. + staleNewestSeen?.let { Backfiller.staleRecordBanner(it, nowSec) } + ?: if (backfiller.sawNoFlashCursor) Backfiller.noFlashCursorBanner + else Backfiller.noSensorRecordsBanner + bankedNothing -> null // banked nothing but not yet sustained - stay silent (matches Swift) // #324/#928: the strap banked records but its newest is dated implausibly in the future // (RTC relatched ahead). #773 drops the samples so nothing is misfiled, but this path // would otherwise report a clean sync and leave the user with no data + no reason. @@ -8319,8 +10498,12 @@ class WhoopBleClient( backfillDrain.clear() closeWhoop5BackfillCapture(flushSummary = true) log("Backfill: session ended — reason=$reason") - // Inactivity reminder (#419): read-only hook on the natural offload completion (no cadence - // change). Only on a true HISTORY_COMPLETE — a timeout/disconnect didn't bring a fresh window. + // Downstream export also treats a WHOOP 4 idle timeout with persisted rows as successful: that + // firmware routinely finishes productive offloads without emitting HISTORY_COMPLETE. + if (shouldNotifySuccessfulOffload(reason, persistedSensorRows)) { + runCatching { successfulOffloadSink() } + } + // Existing inactivity/stress/nap hooks retain their stricter HISTORY_COMPLETE semantics. if (reason == "HISTORY_COMPLETE") { maybeBuzzInactivity() // L3 stress check-in (v5): same read-only hook — fire the StressOnsetDetector over the live @@ -8429,6 +10612,28 @@ class WhoopBleClient( ioScope.launch { val frontier = runCatching { repository.latestHrSampleTs(deviceId) }.getOrNull() val wallNow = System.currentTimeMillis() / 1000L // #928: real wall clock, at decision time + // #1164: publish whether the strap has banked records newer than our local frontier, so the + // Today Rest card can show "Pending sync" instead of a provisional number. Same behind check + // the auto-continue predicate uses (5-min gap). Caught-up (or unknown) → false. + // #1164 + #928/#1012 + #1144: the bare gap is not enough. Both traps that + // `shouldAutoContinue` guards against latch this flag TRUE forever, which would pin + // Rest to "Pending sync" and never show a score — strictly worse than the + // provisional number this exists to hide. + // - a strap whose clock is set in the FUTURE reads ahead of ANY frontier, so the + // gap never closes (there is a user-facing banner for exactly that state); + // - a PHANTOM gap (a timestamp the strap will not actually offload, a console-only + // tail, a dup re-offload) advertises newer data while banking no new rows, so the + // frontier cannot advance and the gap stays open. `persistedSensorRows` is the + // same evidence #1144 added to the auto-continue predicate for this exact latch; + // a caught-up strap is already false via the gap, so gating on it only bites the + // phantom case. + val pending = newest != null && frontier != null && + !isFutureDatedNewest(newest, wallNow) && + persistedSensorRows && + (newest - frontier) > AUTO_CONTINUE_BEHIND_GAP_SECONDS + if (_state.value.historyPendingSync != pending) { + _state.value = _state.value.copy(historyPendingSync = pending) + } // #266: local only — NOT cached on the instance. A future-dated newest (#1012) makes the // AUTOMATIC periodic/strap kicks near-useless for THIS decision; [requestSync] recomputes its // own verdict fresh from [strapNewestTs] on every call, so a stale value here can't leak forward. @@ -8573,6 +10778,15 @@ class WhoopBleClient( // MARK: Disconnect / teardown (port of didDisconnectPeripheral) // ==================================================================================== + /** + * Ask Android to delete this device's pairing. Reflection because `removeBond()` is not public API; + * a throw or a `false` is a REFUSAL, not a crash, and the caller reports which happened. + */ + @SuppressLint("MissingPermission") + private fun removeOsBond(device: BluetoothDevice): Boolean = runCatching { + device.javaClass.getMethod("removeBond").invoke(device) as? Boolean ?: false + }.getOrDefault(false) + @SuppressLint("MissingPermission") private fun handleDisconnect(status: Int) { val helloWasUnacked = clientHelloWriteAtMs > 0L @@ -8601,6 +10815,55 @@ class WhoopBleClient( // Snapshot the hold time and clear it IMMEDIATELY: every drop log below reads the snapshot, and a // stale `linkUpSinceMs` surviving into the next drop would report a hold time for a link that never // reached STATE_CONNECTED — the diagnostic would then invent exactly the evidence it exists to find. + // #1809: the epitaph reads linkUpSinceMs BEFORE the snapshot below clears it, and realtimeArmed + // before the state reset further down. Twin of the Apple disconnect epitaph: it answers "did the + // strap send anything on this link", which no strap log could previously state as a measurement. + // Only for a link that actually reached STATE_CONNECTED. handleDisconnect also runs for a FAILED + // connect attempt and for the radio-off teardown, where linkUpSinceMs is null and the counters + // still hold the PREVIOUS link's traffic - emitting there would report "the strap sent NOTHING on + // this link" about a link that never existed, fabricating the very symptom #1809 is about. Same + // hazard the hold-time snapshot below already guards. + linkUpSinceMs?.let { since -> + log(ConnectionReadout.linkEpitaph( + upMillis = System.currentTimeMillis() - since, + inboundFrames = inboundFrames, inboundBytes = inboundBytes, + cmdChannelFrames = cmdChannelFrames, realtimeArmed = realtimeArmedThisLink, + // #1820 parity: Apple's epitaph reports "intentional" for a deliberate teardown rather + // than an error code, and a field log showed Android printing "ended=status=0" for the + // same event. Two reports of the same drop should not read differently. The flag is set + // before handleDisconnect runs and is not reassigned above, so it is valid here. + ended = if (intentionalDisconnect) "intentional" else "status=$status", + )) + // #1635: what the DATABASE gained from the LIVE streams, beside what the radio carried. An + // unbonded 5/MG keeps the epitaph looking healthy — hundreds of inbound frames — while every + // bond-gated stream banks nothing, and that split is invisible in a single export. Live only: + // the offload persists through `Backfiller` and has its own accounting, so folding it in here + // would be double-counted in one direction and, worse, its ABSENCE reads as a fault. + // Guarded by the SAME `linkUpSinceMs` as the epitaph: on a failed connect attempt the counters + // hold the previous link's tally, and reporting them would describe a link that never existed. + log(ConnectionReadout.linkBankedSummary( + liveHr = liveHr.get(), liveRr = liveRr.get(), offloadChunks = offloadChunks.get(), + offloadHr = offloadHr.get(), offloadRr = offloadRr.get(), + offloadGravity = offloadGravity.get(), offloadResp = offloadResp.get(), + offloadSkinTemp = offloadSkinTemp.get(), offloadSpo2 = offloadSpo2.get(), + offloadSteps = offloadSteps.get(), + ), com.noop.testcentre.TestDomain.CONNECTION) + // Frames this link REJECTED, per reason. A per-connection readout, so it sits behind the + // Test Centre's Connection domain (D3) — a resync after a lost notification rejects frames + // routinely and always did, and that number is explicitly NOT the signal to act on. The one + // that is (payload CRC32 verified, envelope not) announces itself when it happens, whether + // or not any test mode is on. Silent when the link rejected nothing. + if (testCentre.active(com.noop.testcentre.TestDomain.CONNECTION)) { + rejectTally.summaryLine()?.let { log(it, com.noop.testcentre.TestDomain.CONNECTION) } + } + } + // Clear the tally with the link, so a second teardown for the same drop cannot re-report it. + inboundFrames = 0; inboundBytes = 0; cmdChannelFrames = 0 + rejectTally.reset(); loggedRejectReasons.clear() + liveHr.set(0); liveRr.set(0); offloadHr.set(0); offloadRr.set(0) + offloadGravity.set(0); offloadResp.set(0); offloadSkinTemp.set(0) + offloadSpo2.set(0); offloadSteps.set(0); offloadChunks.set(0) + val heldSuffix = heldForLogSuffix() linkUpSinceMs = null // Reboot trail: if a user reboot is in flight, this drop is the strap acting on it. Log how long the @@ -8688,8 +10951,21 @@ class WhoopBleClient( staleDirectBond = staleDirectBond, status = status, alreadyPausedForBondLoop = autoReconnectPausedForBondLoop, + // The question is whether the hello was ACTUALLY withheld on this link, not whether the + // latch is set. With the #1635 override in force the latch stays set while we send the + // hello anyway, and treating that as withheld would disable the give-up for exactly the + // case that needs it, leaving an unbounded hello-drop-reconnect loop with nothing to stop + // it. But a SPENT override withholds the hello for real, so this must ask + // [helloOverrideActive] rather than the raw pref — mirroring the `helloOverride` that + // decided whether to send. Reading the pref alone kept the detector counting after the + // hellos had stopped, and would have paused auto-reconnect and raised the stale-pairing + // guide over a cause that never happened. helloSuppressed = runCatching { - com.noop.ui.NoopPrefs.helloSuppressed(context, lastDeviceAddress) + com.noop.ui.NoopPrefs.helloSuppressed(context, lastDeviceAddress) && + !helloOverrideActive( + PuffinExperiment.from(context).helloDespiteBondRefusal, + helloOverrideAttempts, + ) }.getOrDefault(false), ) && bondWatchdogBackoff.recordBounce() ) { @@ -8726,6 +11002,7 @@ class WhoopBleClient( _state.update { it.clearedBiometrics().copy( connected = false, bonded = false, encryptedBond = false, backfilling = false, syncChunksThisSession = 0, + historyPendingSync = false, // #1164: a stale "pending" must not outlive the link charging = null, // a stale charging flag must not outlive the link strapFirmware = null, // nor stale firmware/layout versions historyLayoutVersion = null, @@ -8763,6 +11040,45 @@ class WhoopBleClient( // #520/#891: the DIS strings belong to the link that just dropped; a stale variant must not keep an // MG-only capability unlocked for whatever connects next. _whoop5Variant.value = Whoop5Variant.UNKNOWN + // #1635 / #1804: the probe-link-lost verdict must be emitted BEFORE reset() clears the probe + // flags, and it needs the disconnect `status` (which reset() does not receive). A probe still + // mid-subscribe when the link goes is stage 1 ending with the LINK; a probe mid-GET_CLOCK-wait + // is stage 2. Both get a verdict line so the silence budget advances correctly — EXCEPT when the + // link was terminated LOCALLY (status=22), which is our own stack ending the link and not a strap + // verdict. A local teardown is inconclusive and does NOT charge the budget (#1804). + // + // Cancel the probe runnables BEFORE emitting the verdict, so a runnable already dequeued and + // waiting to run cannot fire on the stale state. reset() cancels them again idempotently. + handler.removeCallbacks(unbondedProbeStartRunnable) + handler.removeCallbacks(unbondedProbeVerdictRunnable) + if (unbondedProbeSubscribing || unbondedProbeAwaitingReply) { + val uptime = if (connectedAtMs > 0L) System.currentTimeMillis() - connectedAtMs else -1L + if (unbondedProbeLinkLostIsLocalTeardown(status)) { + val stage = if (unbondedProbeSubscribing) 1 else 2 + log(unbondedProbeLinkLostLocalTeardownLine( + uptimeMs = uptime, + stage = stage, + localTeardownOrigin = lastLocalTeardown, + )) + // #1804: a local teardown is not a strap verdict, so it does NOT charge the silence + // budget. But it DOES charge the inconclusive budget, so a strap whose every link is + // torn down locally does not retry forever. + chargeUnbondedProbeInconclusive() + } else { + log( + if (unbondedProbeSubscribing) unbondedProbeLinkLostLine( + uptimeMs = uptime, + confirmedSubscribes = unbondedProbeSubscribed, + total = WHOOP5_NOTIFY_CHARS.size, + ) else unbondedProbeLinkLostAskingLine( + uptimeMs = uptime, + waitedMs = if (unbondedProbeAskedAtMs > 0L) + System.currentTimeMillis() - unbondedProbeAskedAtMs else -1L, + ), + ) + chargeUnbondedProbeSilence() + } + } reset() // close() can itself throw DeadObjectException on a dead binder — teardown must NEVER throw, @@ -8810,6 +11126,26 @@ class WhoopBleClient( } if (staleDirectBond) { staleDirectFailures++ + // Before `lastDevice = null` below: that handle is the only device left to act on here. + val staleDevice = lastDevice + if (staleDevice != null && shouldRemoveStaleBond( + optedIn = puffinExperiment.clearStaleBond, + // Belt and braces. `staleDirectBond` can only be set by the Easy-connect path, + // whose helpers match a 5/MG and which pins selectedModel to it — so this is + // already implied. Kept because `connectedFamily` fails CLOSED when it is stale + // (it holds the previous link's value), and the direction that matters is never + // removing a pairing we should not. + isWhoop5 = connectedFamily == DeviceFamily.WHOOP5, + osBonded = runCatching { + staleDevice.bondState == BluetoothDevice.BOND_BONDED + }.getOrDefault(false), + consecutiveStaleFailures = staleDirectFailures, + alreadyRemovedThisRun = staleBondRemoved, + ) + ) { + staleBondRemoved = true + log(staleBondRemovalLine(staleDirectFailures, removeOsBond(staleDevice))) + } log("Disconnected (status=$status) before the bonded fast-path reached a session — stale OS bond (attempt $staleDirectFailures); falling back to a scan") lastDevice = null // Two consecutive wiped-bond failures = the strap really reset its pairing (firmware @@ -8830,7 +11166,7 @@ class WhoopBleClient( } // #1030 (ryanbr): route through scheduleReconnect so this backoff timer is cancellable // and can't tear down a link that returns before it fires. - scheduleReconnect(RECONNECT_DELAY_MS) { connect(selectedModel) } + scheduleReconnect(RECONNECT_DELAY_MS) { connectFromSystem(selectedModel) } return } val dev = lastDevice @@ -8866,7 +11202,7 @@ class WhoopBleClient( val rescanDelay = nextReconnectDelayMs() log("Disconnected (status=$status); rescanning in ${rescanDelay / 1000}s (attempt $failedReconnectAttempts$heldSuffix)") // #1030 (ryanbr): cancellable backoff timer (see scheduleReconnect). - scheduleReconnect(rescanDelay) { connect(selectedModel) } + scheduleReconnect(rescanDelay) { connectFromSystem(selectedModel) } } } else { log("Disconnected (intentional)") @@ -8890,6 +11226,33 @@ class WhoopBleClient( // exactly the gap this stopwatch was added to close. It is overwritten by the next request, and a // stale value can only ever produce a large elapsed against a label that names what it measures. connectHandshakeDone = false + // Mirrors the flag above: a new link has not done the handshake, so it cannot hand over history + // until it does. Cleared HERE and nowhere else, so the two can never disagree. + _state.update { it.copy(historyReady = false) } + // #1635: the probe is per LINK — its whole basis is one stable link's behaviour, and carrying the + // "already asked" flag across a reconnect would let one silent link retire the experiment. The + // REFUSAL, by contrast, is persisted per device: that one is the strap's answer, not the link's. + // Cancel BEFORE clearing the flags: a runnable already dequeued and waiting to run would otherwise + // see the cleared state, which is harmless, but one still queued must not reach the next link. + handler.removeCallbacks(unbondedProbeStartRunnable) + handler.removeCallbacks(unbondedProbeVerdictRunnable) + // The probe-link-lost verdict (stage 1 or 2) is emitted from handleDisconnect BEFORE reset(), + // because it needs the disconnect `status` to classify a local teardown (#1804) and reset() is + // status-agnostic. The flag clears below stay here so the next link starts clean regardless of + // which path reached reset(). + unbondedProbeStartedThisLink = false + unbondedProbeSkipLogged = false + unbondedProbeDeferrals = 0 + disChainInFlight = false + unbondedProbeSubscribed = 0 + unbondedProbeSubscribing = false + unbondedProbeAwaitingReply = false + unbondedProbeEvidence = UnbondedProbeEvidence.NONE + helloWrittenThisLink = false + backfillDeferralsThisLink = 0 + // The deferral run is deliberately NOT cleared: it counts ACROSS connections AND across process + // restarts (it lives in SharedPreferences), which is the only way a permanent deferral is + // distinguishable from a pending one. familyEstablished = false // the next link re-establishes it at service discovery loggedFirmwareGate = null clientHelloWriteAtMs = 0L @@ -9006,6 +11369,18 @@ class WhoopBleClient( // across sessions with per-session ids (his fork truncated per session, losing overnight data); // rotates at the cap; fail-soft — capture can never break the sync it observes. + /** #1635: unanswered hellos the override CAUSED this process. Bounds the experiment on its own, + * because the shared give-up cannot — see HELLO_OVERRIDE_MAX_ATTEMPTS. Reset on a genuine bond. */ + @Volatile private var helloOverrideAttempts = 0 + + /** Last opt-in state the connect path saw, so an off->on flip can re-arm a spent budget (#1635). */ + @Volatile private var helloOverrideOptInSeen = false + + /** One-shot guard for the give-up line. Separate from the counter because `++` on a @Volatile Int is + * not atomic: two overlapping connects could step past the cap together, and an `== cap` test would + * then never fire, leaving the override inert with nothing in the log to say why. */ + @Volatile private var helloOverrideExhaustedLogged = false + @Volatile private var captureWriter: java.io.BufferedWriter? = null @Volatile private var captureDisabled = false @Volatile private var captureLines = 0 @@ -9037,7 +11412,13 @@ class WhoopBleClient( val w = captureWriter ?: return runCatching { val parsed = Framing.parseFrame(frame, connectedFamily) - captureSummary.record(parsed.typeName, parsed.crcOk, frame.size, characteristic, frame.toHex()) + // PARSEABILITY, not integrity: the type the decoder read is kept whatever the verdict was, + // because a capture exists to map frames nothing else can read. The verdict travels beside + // it as the reject reason. + captureSummary.record( + parsed.typeName, parsed.crcOk, frame.size, characteristic, frame.toHex(), + parsed.rejectReason, + ) val line = BackfillCaptureJsonl.encode( BackfillCaptureRecord( capturedAtMs = System.currentTimeMillis(), @@ -9049,6 +11430,7 @@ class WhoopBleClient( size = frame.size, parsed = parsed.parsed, hex = frame.toHex(), + rejectReason = parsed.rejectReason, ), ) var checkBytes = false @@ -9129,27 +11511,19 @@ class WhoopBleClient( * one previous generation. Cheap for every other frame: a length + single-byte compare BEFORE the * pref read; no-op unless the capture toggle is on. */ - /** #423: persist the WHOOP 5/MG raw-IMU offload buffer NOOP already decodes for the deep-buffer log — - * the queryable twin of that (table-less) diagnostics line. Same `isCaptureEnabled` gate; only the - * 1244-B 6-axis buffer decodes (rawColumns null otherwise). IO-dispatched so it never blocks the GATT - * thread; bounded by a rolling retention prune. Raw i16, no downstream consumer yet (instrument-first). */ + /** Debug heartbeat for valid WHOOP 5/MG raw-IMU buffers. Session persistence is file-only. */ private fun storeWhoop5RawImuIfBuffer(frame: ByteArray) { + // This compatibility cache keeps its original opt-in gate. Bounded sessions are file-backed. if (!PuffinExperiment.from(context).isCaptureEnabled) return val cols = Whoop5RawImu.rawColumns(frame) ?: return val baseTs = PuffinDeepBufferLog.strapTs(frame)?.toLong() ?: return - val dev = deviceId // #423 debug heartbeat: confirm the offload IMU is arriving + decoding on-device without pulling the // JSONL. Throttled (first buffer, then every 500) so a large offload can't flood the strap log; the // count is a per-connection running total. Off unless raw capture is enabled (gated above). rawImuDecodedCount++ if (rawImuDecodedCount == 1 || rawImuDecodedCount % 500 == 0) { log("RAW IMU capture: $rawImuDecodedCount buffer(s) decoded, latest ts=$baseTs " + - "(${cols.size / 6} samples/axis) — storing (retain ${WhoopRepository.RAW_IMU_RETENTION_ROWS})") - } - val row = RawImuSampleEntity(dev, baseTs, StreamPersistence.packImuColumns(cols)) - ioScope.launch { - runCatching { repository.insertRawImu(dev, listOf(row)) } - .onFailure { log("RAW IMU capture: store failed (${it.message})") } + "(${cols.size / 6} samples/axis) — routed to matching file sessions") } } @@ -9195,6 +11569,13 @@ class WhoopBleClient( } } + /** + * #1303: let the identity owner write one line into the SAME strap log the connection uses, so an + * adoption is visible in the capture beside the DIS line that triggered it. Deliberately narrow — + * the general [log] stays private. Callers must pass a serial PREFIX, never a full serial. + */ + fun logIdentity(line: String) = log(line) + private fun log(s: String, domain: com.noop.testcentre.TestDomain? = null) { // A diagnostic log line must NEVER be able to crash the app. log() runs on the GATT binder // thread and from the background reconnect service, so an uncaught throw here takes the WHOLE @@ -9449,10 +11830,57 @@ class WhoopBleClient( // replace() threw IndexOutOfBoundsException("No group 3"), and the thrown exception aborted that // strap's activation. The WHOOP path never hit it because it only ever logs "WHOOP ", never // a raw MAC, so the bug was invisible until a Polar H10 / other 0x180D strap was used.) -// • WHOOP serial: the device name carries it ("WHOOP 4C1594026"); the dotted model names ("WHOOP 4.0") -// are too short / dotted to match. +// • WHOOP serial: the device name carries it ("WHOOP 4C1594026"). This used to require a DIGIT straight +// after "WHOOP ", but real serials start with letters as often as digits - a field log had +// "WHOOP MGB0779473" sitting unredacted next to a masked "WHOOP 4C1594026". Any alnum run of 6+ that +// CONTAINS a digit now matches. +// +// The digit requirement is what keeps this from eating words: "WHOOP PUFFIN service 1150" is a real +// diagnostic line and PUFFIN is six alnum characters. A serial always carries a digit; a word does +// not. The dotted model names ("WHOOP 4.0") stay untouched for a different reason - the dot ends the +// run at one character, short of the six the lookahead demands. private val PII_MAC_RE = Regex("([0-9A-Fa-f]{2}):[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:([0-9A-Fa-f]{2})") -private val PII_WHOOP_SERIAL_RE = Regex("WHOOP (\\d[0-9A-Za-z]{5,})") +private val PII_WHOOP_SERIAL_RE = + Regex("WHOOP (?=[0-9A-Za-z]{6,})[0-9A-Za-z]*[0-9][0-9A-Za-z]*") + +/** + * The account holder's NAME, as WHOOP writes it into the advertised local name. + * + * WHOOP names a strap "'s Whoop" by default, and the scan path logs that name on every + * discovery, so the shareable strap log (#445) — the file we ask people to attach to public issues — + * carried a real person's name on both platforms. No other rule here could see it: they all key on + * MAC shape, a "WHOOP " + digit serial, or a "whoop-" id. + * + * Keeps the possessive and whatever follows it, so "Ryan's WHOOP 4.0" becomes "'s WHOOP 4.0" and + * the MODEL survives — that part is diagnostic and identifies nobody. Matches a curly apostrophe too: + * Apple platforms write U+2019 into default device names, so a straight-quote-only rule would miss the + * iOS half of the logs entirely. + * + * LIMITATION, deliberate: exactly ONE token before the possessive. "Ryan B's Whoop" keeps "Ryan". A + * multi-token rule cannot tell a name apart from the surrounding log text and would eat "Discovered" + * along with it, mangling the line. This covers WHOOP's default naming, which is what the logs contain; + * a fully custom name with no possessive is not detectable here and stays a known gap. + */ +private val PII_DEVICE_NAME_RE = Regex("[\\p{L}\\p{N}_.\\-]+(['\u2019]s\\s+(?i:whoop))") + +/** + * #1303: a device id that has ADOPTED its strap serial (`whoop-`) is a device identifier in every + * line that prints an id — the Devices list, each `dayOwner`, the per-source counts. Neither existing rule + * catches it: [PII_MAC_RE] wants MAC shape, and [PII_WHOOP_SERIAL_RE] wants the literal word "WHOOP " + * followed by a DIGIT, while an adopted id is `whoop-` + a serial that commonly starts with a letter. + * Before adoption existed no device id could contain a serial, so this was not a gap; it is one now. + * + * Keeps the leading three characters, the same rule `WhoopSerialIdentity.logSafe` already applies to the + * adoption line, so two straps stay distinguishable in a log. The `-noop` computed-sibling suffix is + * PRESERVED: it is not identifying, and it is what lets a reader tell derived rows from measured ones — + * the distinction the "Days:"/"Stored:" lines are read for. + * + * The six-character minimum is not arbitrary: it matches `WhoopSerialIdentity.minSerialLength`, so + * anything short enough to be refused as a serial is also too short to be mistaken for one here. That + * also leaves `my-whoop`, `my-whoop-noop` and the MAC form (already masked to `whoop-FD:••…`) untouched. + */ +private val PII_ADOPTED_ID_NOOP_RE = Regex("whoop-([A-Za-z0-9]{3})[A-Za-z0-9-]{3,}(-noop)") +private val PII_ADOPTED_ID_RE = Regex("whoop-([A-Za-z0-9]{3})[A-Za-z0-9-]{3,}") /** * Builds the 9-byte WHOOP 4.0 SET_ALARM_TIME (cmd 66) payload. @@ -9523,10 +11951,12 @@ internal fun isPlausibleAlarmEpoch(epoch: Long): Boolean = epoch in 1_500_000_00 /** * Extract the armed-alarm epoch from a GET_ALARM_TIME (cmd 67) COMMAND_RESPONSE, defensively (#401 - * close-out). The WHOOP 4.0 response layout is UNDOCUMENTED, so this tries the two shapes the firmware - * could plausibly answer with - the SET_ALARM_TIME mirror (`[form 0x01][u32 LE epoch]…`, matching the - * 9-byte payload we arm with) first, then a bare leading u32 LE - and accepts a candidate only when it - * passes [isPlausibleAlarmEpoch]. Anything else returns null and the caller logs raw hex instead. + * close-out). The WHOOP 4.0 response layout is UNDOCUMENTED, so this tries the shapes the firmware has + * been seen to answer with - the 11-byte GET readback captured on fw 41.17.6.0 + * (`[form 0x01][stored flag][u32 LE epoch][00 00][04 00 20]`, epoch at offset 2) first, then the + * SET_ALARM_TIME mirror (`[form 0x01][u32 LE epoch]…`, matching the 9-byte payload we arm with), then + * a bare leading u32 LE - and accepts a candidate only when it passes [isPlausibleAlarmEpoch]. + * Anything else returns null and the caller logs raw hex instead. * Pinned by `AlarmReadbackDecodeTest`; twin of the Swift `FrameRouter.armedAlarmEpoch`. */ internal fun whoop4ArmedAlarmEpoch(frame: ByteArray): Long? { @@ -9538,6 +11968,18 @@ internal fun whoop4ArmedAlarmEpoch(frame: ByteArray): Long? { ((payload[at + 2].toLong() and 0xFFL) shl 16) or ((payload[at + 3].toLong() and 0xFFL) shl 24) } + // The GET readback (fw 41.17.6.0, three arm/readback captures 2026-08-26..28, #34/#1706): the + // epoch sits ONE byte further than in the SET mirror, because the readback carries a stored flag + // (0x00 = nothing stored, 0x01 = stored) the arm payload does not. The mirror-offset read of this + // shape returns the epoch's LOW THREE bytes shifted up a byte, plus the flag — wrong by roughly + // 256x and free to land anywhere in u32 range. In all three captures it landed on a 2045 date + // INSIDE the 2017..2100 plausibility window (an arm for 2026-08-26 read back as 2045-09-24), so + // the gate did not catch it and a MISMATCH was counted against a strap whose register is fine. So + // on this shape the mirror offsets are known-wrong and must NOT be tried: offset 2 decodes, or + // the payload falls to the raw-hex line. + if (payload.size == 11 && payload[0] == 0x01.toByte()) { + return u32le(2)?.takeIf { isPlausibleAlarmEpoch(it) } + } if (payload.isNotEmpty() && payload[0] == 0x01.toByte()) { u32le(1)?.takeIf { isPlausibleAlarmEpoch(it) }?.let { return it } } @@ -9546,8 +11988,10 @@ internal fun whoop4ArmedAlarmEpoch(frame: ByteArray): Long? { /** * True when a GET_ALARM_TIME readback explicitly reports NO alarm stored — the epoch field decodes to - * 0 in the same shapes [whoop4ArmedAlarmEpoch] reads (SET-mirror `[0x01][u32=0]` first, then a bare - * leading `u32=0`). This is the strap's "nothing armed" sentinel, distinct from a genuinely unparseable + * 0 in the same shapes [whoop4ArmedAlarmEpoch] reads (the 11-byte GET readback `[0x01][flag][u32=0]…` + * first — the #34 field-report payload `01 00 00 00 00 00 00 00 04 00 20` is exactly this shape with + * the stored flag 0x00 — then the SET-mirror `[0x01][u32=0]`, then a bare leading `u32=0`). This is + * the strap's "nothing armed" sentinel, distinct from a genuinely unparseable * payload: an arm the strap silently dropped reads back as epoch 0, so labelling it "unrecognised" hid * the real signal (#34). Only consulted AFTER [whoop4ArmedAlarmEpoch] returns null. Twin of the Swift * `FrameRouter.readbackReportsNoAlarm`; pinned by `AlarmReadbackDecodeTest`. @@ -9561,6 +12005,9 @@ internal fun whoop4ReadbackReportsNoAlarm(frame: ByteArray): Boolean { ((payload[at + 2].toLong() and 0xFFL) shl 16) or ((payload[at + 3].toLong() and 0xFFL) shl 24) } + if (payload.size == 11 && payload[0] == 0x01.toByte()) { + return u32le(2)?.let { it == 0L } ?: false + } if (payload.isNotEmpty() && payload[0] == 0x01.toByte()) { return u32le(1)?.let { it == 0L } ?: false } @@ -9573,13 +12020,110 @@ internal fun alarmReadbackLocalTime(epochSec: Long): String = java.text.SimpleDateFormat("EEE HH:mm zzz", java.util.Locale.US) .format(java.util.Date(epochSec * 1000L)) +/** + * #1833: a serial hidden inside a HEX payload. The text rules below scrub a serial that is written as + * text; they cannot see one that arrives as `payload=…5742423541503035…`, because the redactor is + * looking at hex digits, not at the ASCII those bytes decode to. Event 109 on a 5/MG carries the strap + * serial in plain ASCII inside its payload, so a hex dump of it walks straight past every rule here — + * into the log a reporter pastes into a public issue. + * + * Decode ANY long hex run, find printable ASCII stretches inside it, and if one looks like a WHOOP + * serial, mask THOSE BYTES back in the hex. Everything else survives untouched, which is the point: the + * payload is exactly where an undocumented field would be found, so blanket-truncating it would remove + * the reason the dump exists. + * + * Deliberately NOT keyed on `payload=` / `frame=`. Both platforms label hex differently and + * inconsistently — `payload=`, `frame=`, `[raw …]`, `(raw …)`, the #900 whole-frame dump — and a rule + * that enumerates today's labels is a rule the next diagnostic slips past. Matching the hex itself + * needs no maintenance. The false-positive cost is negligible: masking requires nine consecutive + * alphanumeric ASCII bytes, which random binary produces about once in a million runs. + */ +private val PII_HEX_DUMP_RE = Regex("[0-9a-fA-F]{16,}") +/** A WHOOP serial as it appears in a payload: a leading letter then 8+ alphanumerics (e.g. WBB5AP0539852). */ +private val PII_SERIAL_IN_ASCII_RE = Regex("[A-Za-z][0-9A-Za-z]{8,}") + +internal fun redactHexDumpPii(hex: String): String { + val bytes = ArrayList(hex.length / 2) + var i = 0 + while (i + 1 < hex.length) { + bytes.add(hex.substring(i, i + 2).toIntOrNull(16) ?: return hex) + i += 2 + } + val ascii = StringBuilder(bytes.size) + for (b in bytes) ascii.append(if (b in 32..126) b.toChar() else '.') + var out = hex + for (m in PII_SERIAL_IN_ASCII_RE.findAll(ascii.toString())) { + // Two hex chars per byte: mask exactly the run's bytes, leaving the rest of the dump intact. + val start = m.range.first * 2 + val end = (m.range.last + 1) * 2 + out = out.substring(0, start) + "••".repeat(m.value.length) + out.substring(end) + } + return out +} + +/** + * Tokens that identify a MODEL rather than a person, for [logSafeDeviceName]. + * + * Two shapes only, both EXACT: a known vendor, product or model word, and a version number ("4.0"). + * + * There is deliberately no letters-plus-digits pattern for model codes. One was tried and it defeated + * the whole design: "[a-z]{1,4}\\d{1,3}" matches "Ryan1" and "Sam99" as readily as "H10", so a first + * name with a digit passed through untouched. A pattern cannot be an allowlist - the moment a rule + * describes a SHAPE rather than a known value, it admits everything else of that shape. Model codes are + * therefore listed one by one. + * + * The cost is that an unlisted device logs as "" until its code is added here, which is the right + * direction to fail: a missing model is an inconvenience, a leaked name is not. + * + * Anything not on this list is DROPPED, which is the point: a naming shape nobody anticipated loses by + * default. Extend it when a device logs as "" and its model is worth having. Swift twin: + * `LiveState.safeDeviceNameToken`. + */ +private val SAFE_DEVICE_NAME_TOKEN_RE = + Regex("(?i)(whoop|mg|polar|verity|sense|wahoo|tickr|garmin|hrm|forerunner|fenix|vantage|ignite|amazfit|huami|zepp|xiaomi|mi|band|coospo|magene|suunto|scosche|rhythm|kickr|tacx|elite|cateye|decathlon|kalenji|geonaute|h6|h7|h9|h10|h64|h808s|oh1|dual|\\d+(\\.\\d+)?)") + +/** + * A device name reduced to what is safe to put in a shared log: the MODEL, never the person. + * + * WHOOP seeds a strap's name from the account holder ("'s Whoop") and people rename straps + * to anything at all. [redactStrapLogPii] can only GUESS which words in a line are a name; here the + * whole string IS the advertised name, so the safe move is an ALLOWLIST - keep the tokens known to name + * a model and drop everything else. A naming shape nobody anticipated is then dropped by default rather + * than needing a rule to catch it: "Ryan B's WHOOP 4.0" keeps only "WHOOP 4.0", and "Dad's spare" keeps + * nothing. + * + * The "no name advertised" sentinel survives, because "we saw no name" and "we removed a name" are + * different facts to whoever reads the log. Swift twin: `LiveState.logSafeDeviceName`. + */ +internal fun logSafeDeviceName(name: String?): String { + val n = name?.trim().orEmpty() + if (n.isEmpty() || n == "unknown") return "unknown" + val tokens = n.split(Regex("\\s+")) + val safe = tokens.filter { SAFE_DEVICE_NAME_TOKEN_RE.matches(it) } + // Say "" only when something was actually removed. An unrenamed "WHOOP 4.0" or "Polar H10" + // carries nothing personal, and prefixing it would claim a redaction that never happened. + return when { + safe.size == tokens.size -> n + safe.isEmpty() -> "" + else -> " " + safe.joinToString(" ") + } +} + /** Mask MAC addresses and WHOOP serials in a strap-log line before it's shown/exported. * TOTAL — never throws: a redaction failure returns a safe placeholder rather than leaking the raw * line or crashing the caller (#453). The MAC regex captures exactly two groups (first + last octet), * so the replacement references $1/$2 only. */ internal fun redactStrapLogPii(s: String): String = try { - s.replace(PII_MAC_RE, "$1:••:••:••:••:$2") + s.replace(PII_HEX_DUMP_RE) { m -> redactHexDumpPii(m.value) } + .replace(PII_MAC_RE, "$1:••:••:••:••:$2") .replace(PII_WHOOP_SERIAL_RE, "WHOOP ") + // MAC first, deliberately: `whoop-` is already `whoop-FD:••…` by now and cannot be mistaken + // for an adopted id. The -noop form runs before the general one so the sibling suffix survives. + .replace(PII_ADOPTED_ID_NOOP_RE, "whoop-$1…$2") + .replace(PII_ADOPTED_ID_RE, "whoop-$1…") + // Last: the name rule keys on literal text no earlier rule produces or consumes, so it neither + // masks a substitution marker nor depends on one. + .replace(PII_DEVICE_NAME_RE, "$1") } catch (t: Throwable) { "[redaction error - line withheld]" } diff --git a/android/app/src/main/java/com/noop/ble/WhoopConnectionService.kt b/android/app/src/main/java/com/noop/ble/WhoopConnectionService.kt index 196e6f3d0c..0f0b8ce962 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopConnectionService.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopConnectionService.kt @@ -206,6 +206,10 @@ class WhoopConnectionService : Service() { * The detector is reset each time we (re)enter a window. */ private val sleepWatcher = SleepWindowWatcher() private var inAlarmWindow = false + /** Whether this window has already logged a live-HR reading (#1858). The window-open line is worth + * one line a night, and it must report a REAL reading: `heartRate ?: 0` means the collector also + * runs when nothing is streaming, which is the very case the line exists to rule out. */ + private var loggedAlarmWindowHr = false /** The smart-alarm HR collector, alive for the life of the service. */ private var alarmJob: Job? = null @@ -223,7 +227,13 @@ class WhoopConnectionService : Service() { private val bondStateReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action != BluetoothDevice.ACTION_BOND_STATE_CHANGED) return - val dev: BluetoothDevice? = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) + val dev: BluetoothDevice? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) + } val cur = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.ERROR) val prev = intent.getIntExtra(BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE, BluetoothDevice.ERROR) runCatching { ble.onBondStateChanged(prev, cur, dev?.address) } @@ -512,15 +522,71 @@ class WhoopConnectionService : Service() { .conflate() .collect { hr -> if (!store.enabled || store.scheduledDeadlineMs <= 0L) { + // Disarmed while we were inside the window. SmartAlarmReceiver zeroes the + // edges before re-arming, and an explicit disable zeroes them for good, so + // without this the end-of-window line is simply lost — and a window line with + // no end line is documented to mean the HR stream stopped, which would be the + // wrong reading. Log it here so every opened window closes with a line. + if (inAlarmWindow) { + ble.externalLog( + "Smart alarm: wake window ended (alarm no longer armed), detector " + + "${if (sleepWatcher.hasFired) "fired" else "never fired"} - " + + "${sleepWatcher.trough?.let { "trough $it bpm" } ?: "trough never established"} " + + "from ${sleepWatcher.samples} readings", + ) + } inAlarmWindow = false return@collect } val now = System.currentTimeMillis() val inWindow = now in store.scheduledWindowStartMs until store.scheduledDeadlineMs - if (inWindow && !inAlarmWindow) sleepWatcher.reset() // fresh night + if (inWindow && !inAlarmWindow) { + sleepWatcher.reset() // fresh night + loggedAlarmWindowHr = false + } + // #1858: the alarm package had no logging at all, so "the smart alarm never works" + // could not be told apart from "nothing was streaming all night" — the two need + // opposite fixes. These lines are diagnostics ONLY; every decision below is + // unchanged. + // + // Reading an exported log. It takes BOTH the window line and the end line, because + // the end line carries the sample count and only that separates a detector that + // declined to fire from a stream that stopped underneath it: + // + // window + advance -> worked + // window + end (no fire, samples n) -> HR flowed all window and it still never + // fired: the detector's own defect + // window, NO end line -> the stream stopped before the window ended + // (no post-deadline sample to log on) + // no window + end (samples 0) -> nothing streamed inside the window at all + // neither line -> the collector never ran in-window: the + // service was down, or the alarm was off + if (!inWindow && inAlarmWindow) { + ble.externalLog( + "Smart alarm: wake window ended, detector ${if (sleepWatcher.hasFired) "fired" else "never fired"} " + + "- ${sleepWatcher.trough?.let { "trough $it bpm" } ?: "trough never established"} " + + "from ${sleepWatcher.samples} readings", + ) + } inAlarmWindow = inWindow if (!inWindow) return@collect + if (hr > 0 && !loggedAlarmWindowHr) { + loggedAlarmWindowHr = true + val mins = (store.scheduledDeadlineMs - now) / 60_000L + ble.externalLog("Smart alarm: in wake window with live HR $hr bpm, deadline in $mins min") + } if (sleepWatcher.shouldWake(hr)) { + // advanceTo() returns silently when the OS will not honour an exact alarm (the + // API 31+ permission can be revoked AFTER the alarm was armed, leaving a + // deadline that can never be moved). Claiming "advancing" would then assert + // something this line cannot attribute - and that is precisely the case someone + // reading the log would be hunting. State the request and the permission apart. + val permitted = SmartAlarmScheduler.canScheduleExact(this@WhoopConnectionService) + ble.externalLog( + "Smart alarm: detector fired, asking to advance the wake - HR $hr bpm vs " + + "trough ${sleepWatcher.trough} after ${sleepWatcher.samples} readings" + + if (permitted) "" else " - IGNORED, exact alarms are not permitted", + ) SmartAlarmScheduler.advanceTo(this@WhoopConnectionService, store, now) } } diff --git a/android/app/src/main/java/com/noop/data/BackupSettings.kt b/android/app/src/main/java/com/noop/data/BackupSettings.kt index ed8b0de398..4b7d79df4b 100644 --- a/android/app/src/main/java/com/noop/data/BackupSettings.kt +++ b/android/app/src/main/java/com/noop/data/BackupSettings.kt @@ -40,8 +40,8 @@ object BackupSettingsCodec { * (platform-neutral) names. Mirrors the Apple `BackupSettings.whitelist` exactly. * * Profile: the body metrics that power HR zones / calories / recovery baselines, plus the manual - * HR-max override (`profile.hrMax`, 0 = auto/Tanaka). Display: the metric/imperial system, the - * separate temperature override ("" = match the system), and the Effort axis (#268). Deliberately + * HR-max override (`profile.hrMax`, 0 = auto/Tanaka). Display: the body and exercise-distance + * systems, separate temperature override ("" = follow body), and the Effort axis (#268). Deliberately * EXCLUDED: step calibration (per-strap, not per-person), the steps-engine fitted outputs * (derived), and every noop.* toggle that is device- or install-specific. */ @@ -54,8 +54,11 @@ object BackupSettingsCodec { "profile.hrMax" to Kind.INT, "profile.hrZoneThresholds" to Kind.STRING, "units.system" to Kind.STRING, + "units.distance" to Kind.STRING, "units.temperature" to Kind.STRING, + "units.skinTempDisplay" to Kind.STRING, // #1846, carried like the other display units "effort.scale" to Kind.STRING, + "dayCycle.mode" to Kind.STRING, // The ONE layout pref carried (#today-hosted-cards): the Trends/Sleep cards the user chose to host // in Today, a JSON [String] of ids. Unlike section order, this is a deliberate composition the user // built and expects across a restore. Its POSITION (the addedCards slot in today.sectionOrder) is @@ -142,12 +145,23 @@ object BackupSettingsBridge { if (noop.contains(NoopPrefs.KEY_UNIT_SYSTEM)) { noop.getString(NoopPrefs.KEY_UNIT_SYSTEM, null)?.let { values["units.system"] = it } } + if (noop.contains(NoopPrefs.KEY_DISTANCE_UNIT_SYSTEM)) { + noop.getString(NoopPrefs.KEY_DISTANCE_UNIT_SYSTEM, null)?.let { values["units.distance"] = it } + } if (noop.contains(NoopPrefs.KEY_TEMPERATURE_UNIT)) { noop.getString(NoopPrefs.KEY_TEMPERATURE_UNIT, null)?.let { values["units.temperature"] = it } } + // #1846: carried like the other display units. The whitelist entry alone does NOTHING — export and + // import are both explicit per key here, so a whitelisted-but-unwired pref silently never restores. + if (noop.contains(NoopPrefs.KEY_SKIN_TEMP_DISPLAY)) { + noop.getString(NoopPrefs.KEY_SKIN_TEMP_DISPLAY, null)?.let { values["units.skinTempDisplay"] = it } + } if (noop.contains(UnitPrefs.KEY_EFFORT_SCALE)) { noop.getString(UnitPrefs.KEY_EFFORT_SCALE, null)?.let { values["effort.scale"] = it } } + if (noop.contains(NoopPrefs.KEY_DAY_CYCLE_MODE)) { + noop.getString(NoopPrefs.KEY_DAY_CYCLE_MODE, null)?.let { values["dayCycle.mode"] = it } + } if (noop.contains(HostedCardPrefs.KEY_SELECTION)) { noop.getString(HostedCardPrefs.KEY_SELECTION, null)?.let { values[HostedCardPrefs.KEY_SELECTION] = it } } @@ -180,12 +194,20 @@ object BackupSettingsBridge { val editor = NoopPrefs.of(context).edit() (values["units.system"] as? String)?.let { editor.putString(NoopPrefs.KEY_UNIT_SYSTEM, it) } + (values["units.distance"] as? String)?.let { editor.putString(NoopPrefs.KEY_DISTANCE_UNIT_SYSTEM, it) } (values["units.temperature"] as? String)?.let { raw -> - // "" is the Apple side's "match the length/mass system"; here that state is key-absent. + // "" is the Apple side's "follow body measurements"; here that state is key-absent. if (raw.isEmpty()) editor.remove(NoopPrefs.KEY_TEMPERATURE_UNIT) else editor.putString(NoopPrefs.KEY_TEMPERATURE_UNIT, raw) } + (values["units.skinTempDisplay"] as? String)?.let { raw -> + // Absent/"" is "lead with a temperature", stored as key-absent — same convention as the + // temperature override above, and what an older backup without this key restores to. + if (raw.isEmpty()) editor.remove(NoopPrefs.KEY_SKIN_TEMP_DISPLAY) + else editor.putString(NoopPrefs.KEY_SKIN_TEMP_DISPLAY, raw) + } (values["effort.scale"] as? String)?.let { editor.putString(UnitPrefs.KEY_EFFORT_SCALE, it) } + (values["dayCycle.mode"] as? String)?.let { editor.putString(NoopPrefs.KEY_DAY_CYCLE_MODE, it) } (values[HostedCardPrefs.KEY_SELECTION] as? String)?.let { editor.putString(HostedCardPrefs.KEY_SELECTION, it) } // #1361: restore custom behaviours — write the names to the legacy custom key, clear stale hidden, // and drop the v2 blob so the next catalog load re-migrates them (restart-gated, #57). Mirrors iOS. diff --git a/android/app/src/main/java/com/noop/data/DataBackup.kt b/android/app/src/main/java/com/noop/data/DataBackup.kt index 32d74b871e..20cc912522 100644 --- a/android/app/src/main/java/com/noop/data/DataBackup.kt +++ b/android/app/src/main/java/com/noop/data/DataBackup.kt @@ -50,7 +50,7 @@ object DataBackup { // restored). Written LAST so older importers that stop at the first .sqlite entry are unaffected. private const val MANIFEST_ENTRY_NAME = BackupManifest.ENTRY_NAME - private const val MAX_BACKUP_SQLITE_BYTES = 2_147_483_648L + internal const val MAX_BACKUP_SQLITE_BYTES = 2_147_483_648L private const val MAX_BACKUP_SETTINGS_BYTES = 1_048_576L /** First 16 bytes of every SQLite 3 file: "SQLite format 3\0". */ @@ -101,8 +101,27 @@ object DataBackup { /** Import failed and the original database is untouched. */ data class Failed(val message: String) : ImportResult + + /** + * The restore stopped ONLY because an entry exceeds [MAX_BACKUP_SQLITE_BYTES]. Distinct from + * [Failed] so the caller can offer to go ahead: the cap is a decompression guard against a + * hostile archive, and a backup the user just picked out of their own files is a different + * threat model than the one it defends against. (#1807) + */ + data class TooLarge(val message: String, val limitBytes: Long) : ImportResult } + /** + * What an export produced. [overRestoreCeiling] is true when the database is past the ceiling the + * RESTORE path enforces, so the caller can say so while the user still has their data — the + * alternative is finding out during a restore, which is the one moment the original is gone. + * + * Measured on the DATABASE, not the finished archive: the archive is deflated and the cap counts the + * DECOMPRESSED stream, so the zip's own size says nothing about whether it can be read back. That is + * also why compressing harder cannot help anyone past it. (#1807) + */ + data class ExportOutcome(val bytes: Long, val overRestoreCeiling: Boolean, val limitBytes: Long) + /** * Export the live database to [uri] as a compressed `.noopbak` (single-entry ZIP). * @@ -111,7 +130,7 @@ object DataBackup { * Throws on failure so the caller can surface the message in a toast/snackbar. */ @Throws(IOException::class) - fun exportTo(context: Context, uri: Uri) { + fun exportTo(context: Context, uri: Uri): ExportOutcome { val appContext = context.applicationContext // Fold the WAL back into the main file so the snapshot is complete. @@ -155,6 +174,8 @@ object DataBackup { val resolver = appContext.contentResolver val output = resolver.openOutputStream(uri) ?: throw IOException("Could not open the chosen file for writing.") + // Assigned inside the transaction below, where the entry is actually written. + var entryBytes = 0L output.use { out -> // #1014: copy the file while HOLDING Room's write transaction. In WAL mode the main // file is only rewritten by a checkpoint, and a checkpoint only runs on a commit — so @@ -166,7 +187,12 @@ object DataBackup { db.runInTransaction { ZipOutputStream(out).use { zip -> zip.putNextEntry(ZipEntry(ZIP_ENTRY_NAME)) - dbFile.inputStream().use { input -> input.copyTo(zip) } + // #1807: count what actually LANDS in the entry, rather than reading dbFile.length() + // afterwards. The copy runs inside this transaction against a snapshot; once it + // commits, Room can checkpoint again and the main file's length can move, so a later + // read is a different number from the one the restore path will meet. The cap is + // enforced on this entry, so this is the only measurement that answers the question. + dbFile.inputStream().use { input -> entryBytes = input.copyTo(zip) } zip.closeEntry() if (settingsJson != null) { zip.putNextEntry(ZipEntry(SETTINGS_ENTRY_NAME)) @@ -179,6 +205,13 @@ object DataBackup { } } } + // #1807: the file is written and valid either way — this only reports whether restoring it will + // need the user to confirm. + return ExportOutcome( + bytes = entryBytes, + overRestoreCeiling = overRestoreCeiling(entryBytes), + limitBytes = MAX_BACKUP_SQLITE_BYTES, + ) } /** @@ -190,7 +223,7 @@ object DataBackup { * On any error the current database is left exactly as it was. On success the caller * MUST instruct the user to fully restart the app. */ - fun importFrom(context: Context, uri: Uri): ImportResult { + fun importFrom(context: Context, uri: Uri, allowOversize: Boolean = false): ImportResult { val appContext = context.applicationContext val resolver = appContext.contentResolver @@ -214,7 +247,8 @@ object DataBackup { val tempSettings = File(appContext.cacheDir, "import-settings.json") tempSettings.delete() try { - when (stageBackupSqlite(resolver.openInputStream(uri), header, tempSqlite, tempSettings)) { + when (stageBackupSqlite(resolver.openInputStream(uri), header, tempSqlite, tempSettings, + allowOversize = allowOversize)) { StageResult.OK -> Unit StageResult.CANNOT_OPEN -> return ImportResult.Failed("Could not open the chosen file.") StageResult.NO_DB_IN_ZIP -> { @@ -224,7 +258,14 @@ object DataBackup { StageResult.ENTRY_TOO_LARGE -> { tempSqlite.delete() tempSettings.delete() - return ImportResult.Failed("The backup archive is too large to restore safely.") + // #1807: recoverable, so the caller gets a case it can offer to override rather than + // a dead-end message. + // Carries the SAME sentence the old Failed branch showed, deliberately: it is already + // in the audit baseline, so surfacing the override needs no new untranslated copy. + return ImportResult.TooLarge( + "The backup archive is too large to restore safely.", + MAX_BACKUP_SQLITE_BYTES, + ) } StageResult.NOT_A_BACKUP -> return ImportResult.Failed( "That file is not a NOOP backup - it doesn't look like a .noopbak archive or a SQLite database." @@ -405,6 +446,8 @@ object DataBackup { header: ByteArray, dest: File, settingsDest: File? = null, + /** #1807: lift the SQLite cap for a file the USER chose. See [ImportResult.TooLarge]. */ + allowOversize: Boolean = false, ): StageResult { if (input == null) return StageResult.CANNOT_OPEN input.use { stream -> @@ -419,7 +462,7 @@ object DataBackup { !entry.isDirectory && !foundDb && entry.name.substringAfterLast('/') == ZIP_ENTRY_NAME -> { FileOutputStream(dest).use { out -> - if (!copyBounded(zip, out, MAX_BACKUP_SQLITE_BYTES)) { + if (!copyBounded(zip, out, sqliteCap(allowOversize))) { dest.delete() return StageResult.ENTRY_TOO_LARGE } @@ -446,7 +489,7 @@ object DataBackup { } header.startsWith(SQLITE_MAGIC) -> { FileOutputStream(dest).use { out -> - if (!copyBounded(stream, out, MAX_BACKUP_SQLITE_BYTES)) { + if (!copyBounded(stream, out, sqliteCap(allowOversize))) { dest.delete() return StageResult.ENTRY_TOO_LARGE } @@ -458,6 +501,20 @@ object DataBackup { } } + /** + * Whether a database of [entryBytes] is past the ceiling the RESTORE path enforces (#1807). + * + * Strictly greater: a database exactly at the cap still restores, because `copyBounded` refuses only + * when a write would take it OVER. Extracted so the boundary is testable — proving it end to end + * would need a >2 GiB fixture, and a test that recomputes the comparison and asserts its own + * arithmetic proves nothing. + */ + internal fun overRestoreCeiling(entryBytes: Long): Boolean = entryBytes > MAX_BACKUP_SQLITE_BYTES + + /** The SQLite cap, lifted when the user has chosen to go ahead for a file they picked. (#1807) */ + internal fun sqliteCap(allowOversize: Boolean): Long = + if (allowOversize) Long.MAX_VALUE else MAX_BACKUP_SQLITE_BYTES + private fun copyBounded(input: java.io.InputStream, out: java.io.OutputStream, cap: Long): Boolean { val buffer = ByteArray(DEFAULT_BUFFER_SIZE) var total = 0L diff --git a/android/app/src/main/java/com/noop/data/DeviceRegistry.kt b/android/app/src/main/java/com/noop/data/DeviceRegistry.kt index 22ec1f9cf2..33d5f7a350 100644 --- a/android/app/src/main/java/com/noop/data/DeviceRegistry.kt +++ b/android/app/src/main/java/com/noop/data/DeviceRegistry.kt @@ -89,32 +89,15 @@ class DeviceRegistry( } else { dao.upsertPairedDevice(active.copy(id = serialId)) } - dao.reKeyHr(activeId, serialId); dao.deleteHrFor(activeId) - dao.reKeyRr(activeId, serialId); dao.deleteRrFor(activeId) - dao.reKeySpo2(activeId, serialId); dao.deleteSpo2For(activeId) - dao.reKeySkinTemp(activeId, serialId); dao.deleteSkinTempFor(activeId) - dao.reKeyResp(activeId, serialId); dao.deleteRespFor(activeId) - dao.reKeyGravity(activeId, serialId); dao.deleteGravityFor(activeId) - dao.reKeySteps(activeId, serialId); dao.deleteStepsFor(activeId) - dao.reKeyPpgHr(activeId, serialId); dao.deletePpgHrFor(activeId) - dao.reKeyPpgWaveform(activeId, serialId); dao.deletePpgWaveformFor(activeId) - dao.reKeyRawImu(activeId, serialId); dao.deleteRawImuFor(activeId) - dao.reKeyV18Aux(activeId, serialId); dao.deleteV18AuxFor(activeId) - dao.reKeyEvents(activeId, serialId); dao.deleteEventsFor(activeId) - dao.reKeyBattery(activeId, serialId); dao.deleteBatteryFor(activeId) - dao.reKeyDailyMetrics(activeId, serialId); dao.deleteDailyMetricsFor(activeId) - dao.reKeySleepSessions(activeId, serialId); dao.deleteSleepSessionsFor(activeId) - dao.reKeyJournal(activeId, serialId); dao.deleteJournalFor(activeId) - dao.reKeyWorkouts(activeId, serialId); dao.deleteWorkoutsFor(activeId) - dao.reKeyAppleDaily(activeId, serialId); dao.deleteAppleDailyFor(activeId) - dao.reKeyAppleStepHour(activeId, serialId); dao.deleteAppleStepHoursFor(activeId) - dao.reKeyMetricSeries(activeId, serialId); dao.deleteMetricSeriesFor(activeId) - dao.reKeyDayOwnership(activeId, serialId); dao.deleteDayOwnershipFor(activeId) - dao.reKeySleepStates(activeId, serialId); dao.deleteSleepStatesFor(activeId) - dao.reKeyLabMarkers(activeId, serialId); dao.deleteLabMarkersFor(activeId) - dao.reKeyLiveSessions(activeId, serialId); dao.deleteLiveSessionsFor(activeId) - dao.reKeyDismissedWorkouts(activeId, serialId); dao.deleteDismissedWorkoutsFor(activeId) - dao.reKeyDismissedSleeps(activeId, serialId); dao.deleteDismissedSleepsFor(activeId) + // BOTH id shapes. Every strap also owns a COMPUTED sibling keyed `-noop` (see + // WhoopRepository.computedDeviceId) holding the scored days, detected workouts and metric + // series the engine derives. That id never equals activeId, so re-keying only the pairing's + // own id left the computed history stranded under an id nothing reads again while the next + // scoring pass wrote under `-noop` — the orphaned history this adoption exists to + // prevent, displaced onto the computed half. A ring has no computed sibling, which is why the + // shipped Oura path never surfaced it. + reKeyDeviceScopedRows(activeId, serialId) + reKeyDeviceScopedRows(activeId + COMPUTED_SUFFIX, serialId + COMPUTED_SUFFIX) dao.deletePairedDeviceRow(activeId) dao.deleteDeviceRow(activeId) true @@ -126,6 +109,41 @@ class DeviceRegistry( suspend fun touchLastSeen(id: String, now: Long = System.currentTimeMillis() / 1000) = dao.touchLastSeen(id, now) + /** Suffix of the COMPUTED sibling every device id owns; twin of `WhoopRepository.computedDeviceId` + * and the Swift `DeviceRegistryStore.computedSuffix`. */ + private val COMPUTED_SUFFIX = "-noop" + + /** Move every device-scoped row from [from] onto [to], canonical winning a PK clash, then clear the + * source. Registry rows are deliberately NOT touched here: a computed sibling has none, so the + * pairedDevice/device deletions stay with the caller and run once, for the real pairing only. */ + private suspend fun reKeyDeviceScopedRows(from: String, to: String) { + dao.reKeyHr(from, to); dao.deleteHrFor(from) + dao.reKeyRr(from, to); dao.deleteRrFor(from) + dao.reKeySpo2(from, to); dao.deleteSpo2For(from) + dao.reKeySkinTemp(from, to); dao.deleteSkinTempFor(from) + dao.reKeyResp(from, to); dao.deleteRespFor(from) + dao.reKeyGravity(from, to); dao.deleteGravityFor(from) + dao.reKeySteps(from, to); dao.deleteStepsFor(from) + dao.reKeyPpgHr(from, to); dao.deletePpgHrFor(from) + dao.reKeyPpgWaveform(from, to); dao.deletePpgWaveformFor(from) + dao.reKeyV18Aux(from, to); dao.deleteV18AuxFor(from) + dao.reKeyEvents(from, to); dao.deleteEventsFor(from) + dao.reKeyBattery(from, to); dao.deleteBatteryFor(from) + dao.reKeyDailyMetrics(from, to); dao.deleteDailyMetricsFor(from) + dao.reKeySleepSessions(from, to); dao.deleteSleepSessionsFor(from) + dao.reKeyJournal(from, to); dao.deleteJournalFor(from) + dao.reKeyWorkouts(from, to); dao.deleteWorkoutsFor(from) + dao.reKeyAppleDaily(from, to); dao.deleteAppleDailyFor(from) + dao.reKeyAppleStepHour(from, to); dao.deleteAppleStepHoursFor(from) + dao.reKeyMetricSeries(from, to); dao.deleteMetricSeriesFor(from) + dao.reKeyDayOwnership(from, to); dao.deleteDayOwnershipFor(from) + dao.reKeySleepStates(from, to); dao.deleteSleepStatesFor(from) + dao.reKeyLabMarkers(from, to); dao.deleteLabMarkersFor(from) + dao.reKeyLiveSessions(from, to); dao.deleteLiveSessionsFor(from) + dao.reKeyDismissedWorkouts(from, to); dao.deleteDismissedWorkoutsFor(from) + dao.reKeyDismissedSleeps(from, to); dao.deleteDismissedSleepsFor(from) + } + /** Archive a device — keeps its row and samples (invariant I4). */ suspend fun archive(id: String) = dao.archiveDevice(id) @@ -188,7 +206,6 @@ class DeviceRegistry( dao.deleteStepsFor(id) dao.deletePpgHrFor(id) dao.deletePpgWaveformFor(id) - dao.deleteRawImuFor(id) // #423 dao.deleteV18AuxFor(id) dao.deleteEventsFor(id) dao.deleteBatteryFor(id) diff --git a/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt b/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt index 7275f2839d..099edec1b6 100644 --- a/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt +++ b/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt @@ -85,7 +85,6 @@ interface DeviceRegistryDao { @Query("DELETE FROM stepSample WHERE deviceId = :deviceId") suspend fun deleteStepsFor(deviceId: String) @Query("DELETE FROM ppgHrSample WHERE deviceId = :deviceId") suspend fun deletePpgHrFor(deviceId: String) @Query("DELETE FROM ppgWaveformSample WHERE deviceId = :deviceId") suspend fun deletePpgWaveformFor(deviceId: String) - @Query("DELETE FROM rawImuSample WHERE deviceId = :deviceId") suspend fun deleteRawImuFor(deviceId: String) // #423 @Query("DELETE FROM v18AuxSample WHERE deviceId = :deviceId") suspend fun deleteV18AuxFor(deviceId: String) @Query("DELETE FROM event WHERE deviceId = :deviceId") suspend fun deleteEventsFor(deviceId: String) @Query("DELETE FROM battery WHERE deviceId = :deviceId") suspend fun deleteBatteryFor(deviceId: String) @@ -126,7 +125,6 @@ interface DeviceRegistryDao { @Query("UPDATE OR IGNORE stepSample SET deviceId = :to WHERE deviceId = :from") suspend fun reKeySteps(from: String, to: String) @Query("UPDATE OR IGNORE ppgHrSample SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyPpgHr(from: String, to: String) @Query("UPDATE OR IGNORE ppgWaveformSample SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyPpgWaveform(from: String, to: String) - @Query("UPDATE OR IGNORE rawImuSample SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyRawImu(from: String, to: String) @Query("UPDATE OR IGNORE v18AuxSample SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyV18Aux(from: String, to: String) @Query("UPDATE OR IGNORE event SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyEvents(from: String, to: String) @Query("UPDATE OR IGNORE battery SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyBattery(from: String, to: String) diff --git a/android/app/src/main/java/com/noop/data/Entities.kt b/android/app/src/main/java/com/noop/data/Entities.kt index cbfa83259d..d769766f41 100644 --- a/android/app/src/main/java/com/noop/data/Entities.kt +++ b/android/app/src/main/java/com/noop/data/Entities.kt @@ -3,6 +3,7 @@ package com.noop.data import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index +import androidx.room.PrimaryKey /* * Room entities mirroring the verified GRDB schema in @@ -328,6 +329,15 @@ data class DailyMetric( // one is unambiguous: always absolute, and only the strap pipeline writes it. Nullable: nights scored // before v34 stay null until a re-score re-derives them from the same raw samples. val skinTempC: Double? = null, + // Whether EVERY sleep session this day was staged from heart rate alone, with no motion to work from + // (#1801). The two vitals below it are not missing by accident on such a night: the HR-only spine + // constructs its sessions with restingHR and avgHRV null on purpose, because sleep bounds inferred + // from heart rate are not firm enough to hang a resting HR or a nightly RMSSD on, and AnalyticsEngine + // then gates both on `!hrOnly`. Persisted so the card can say WHICH of those it is — a blank next to + // a populated respiratory rate reads as a sync failure, and a field log showed a week of exactly that + // misreading. Appended LAST so the column order matches the Room CREATE TABLE and the Swift row. + // Null on every row scored before v36 and on any day with no sleep at all. + val sleepHrOnly: Boolean? = null, ) /** @@ -623,6 +633,28 @@ data class AppleStepHour( val steps: Int, ) +/** + * PRD-K2: one persisted turn in the AI Coach conversation (Room v37 / MIGRATION_36_37). Swift + * `coachMessage` (WhoopStore Database.swift `v43-coach-messages` migration). Lets the Coach chat + * survive relaunch. `orderIndex` (not `createdAt`, which two streamed turns can share to the second) + * is a monotonically-increasing counter so replay order is exact. `provider` isn't filtered on for + * v1 (a conversation is a conversation across a provider switch) but is carried so a future + * per-provider view/filter doesn't need another migration. NEVER added to the `.noopbak` backup + * whitelist — a separate, deliberate decision (CLAUDE.md's backup contract). + * + * Fields are declared in the SAME order as the Swift GRDB schema (id, role, text, provider, + * createdAt, orderIndex) so the migration's CREATE TABLE column order matches Room's generated shape. + */ +@Entity(tableName = "coachMessage") +data class CoachMessageRow( + @PrimaryKey val id: String, + val role: String, // "user" | "assistant" + val text: String, + val provider: String, + val createdAt: Long, // epoch seconds + val orderIndex: Int, // monotonic replay order +) + /** * The RAW WHOOP 5.0 v26 optical PPG waveform, one record per second (v27 / MIGRATION_18_19, issue #156 * follow-up). Swift `ppgWaveformSample` (WhoopStore Database.swift `v27-ppg-waveform` migration). The @@ -637,6 +669,13 @@ data class AppleStepHour( * PK (deviceId, ts) mirrors every other per-second stream; a truncated frame can decode fewer than 24 * samples. Fields are declared in the SAME order as the GRDB schema * (deviceId, ts, samples, burstIndex) so Room's generated shape stays byte-identical. + * + * CAPPED, not unbounded (#1911): [WhoopRepository.PPG_WAVEFORM_RETENTION_ROWS] rolling rows per device, + * the same shape [V18AuxSampleEntity] uses. The cap is NEWEST-N ROWS, never an age cutoff, and that is + * load-bearing for the paragraph above: a sporadic wearer's v26 seconds are spread thin over months, so + * dropping by wall-clock age would empty the table for exactly the user a future re-analysis needs most, + * and a waveform has no aggregate that survives it. Bounding the bytes while always leaving a full working + * set is the whole point. Swift twin: `WhoopStore.ppgWaveformRetentionRows`. */ @Entity(tableName = "ppgWaveformSample", primaryKeys = ["deviceId", "ts"]) data class PpgWaveformSampleEntity( @@ -662,42 +701,6 @@ data class PpgWaveformSampleEntity( } } -/** - * One 1-second WHOOP 5/MG raw-IMU offload buffer (#423): 100 Hz 6-axis inertial data. [samples] is a - * packed little-endian i16 BLOB of the six columns in wire order — ax×100, ay×100, az×100, gx×100, gy×100, - * gz×100 (1200 bytes) — decoded by [com.noop.protocol.Whoop5RawImu] (scales 1/4096 g/LSB, 2000/32768 dps/ - * LSB). The strap already delivers this in the connect-time offload burst; capturing it needs NO arming. - * Instrument-first + bounded: written only when raw capture is enabled, and pruned to a rolling recent - * window ([WhoopRepository.RAW_IMU_RETENTION_ROWS]). Twin of the GRDB `rawImuSample` table. Natural key - * (deviceId, ts) = one row per strap-second. - * - * CONSUMER STATUS (#978): deliberately none yet — instrument-first, the same stance as `v18AuxSample`. The - * writer runs only with raw capture enabled + a 5/MG deep-data unlock; nothing scores, gates or shows a row. - * The [WhoopRepository.rawImuSamples] / [WhoopDao.rawImuSamples] reader is intentionally dormant (zero - * callers) — the eventual cross-check seam, NOT dead code, so do not delete it. The GRDB twin has no reader - * yet by the same rule: one lands WITH a validated consumer, not before. - */ -@Entity(tableName = "rawImuSample", primaryKeys = ["deviceId", "ts"]) -data class RawImuSampleEntity( - val deviceId: String, - val ts: Long, - val samples: ByteArray, -) { - // ByteArray needs structural equals/hashCode (the generated identity ones break round-trip asserts). - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is RawImuSampleEntity) return false - return deviceId == other.deviceId && ts == other.ts && samples.contentEquals(other.samples) - } - - override fun hashCode(): Int { - var result = deviceId.hashCode() - result = 31 * result + ts.hashCode() - result = 31 * result + samples.contentHashCode() - return result - } -} - /** * One Live Session (silent guardian) record (v22 / MIGRATION_15_16). Natural key (deviceId, startTs). * `endTs` is null while the session is still in progress. Fields are declared in the SAME order as the diff --git a/android/app/src/main/java/com/noop/data/SecurePrefs.kt b/android/app/src/main/java/com/noop/data/SecurePrefs.kt index dc8d0a059b..42eb877aae 100644 --- a/android/app/src/main/java/com/noop/data/SecurePrefs.kt +++ b/android/app/src/main/java/com/noop/data/SecurePrefs.kt @@ -10,10 +10,11 @@ import java.util.concurrent.ConcurrentHashMap * The one place an encrypted `SharedPreferences` file is opened. * * Two credential stores needed this and each grew its own copy: `AiKeyStore` (the AI provider API key) - * and `OuraInstallKeyStore` (per-ring install keys). The bodies were byte-identical apart from the file + * and `OuraInstallKeyStore` (per-ring install keys); the self-hosted push bearer token also uses this + * single audited helper. The bodies were byte-identical apart from the file * name, which is the shape that lets a hardening change, a key-scheme migration or a fallback fix land - * on one store and silently miss the other. They are the app's only two encrypted files, so one helper - * covers all of it. + * on one store and silently miss the other. One helper now covers every encrypted preference file, + * including the default-off self-hosted push bearer token. * * **Cached, which is the part that matters at runtime.** `EncryptedSharedPreferences.create` is not a * cheap accessor: it builds a [MasterKey] — an Android Keystore round trip — and sets up Tink AEAD @@ -45,8 +46,8 @@ object SecurePrefs { * * Caveat worth knowing: `ConcurrentHashMap` documents that the mapping function should be "short * and simple", and this one is neither — a Keystore round trip plus Tink setup, with the bin - * locked throughout. It is acceptable here because there are exactly two files, each built once - * per process, and the function never re-enters the map, so the worst case is one thread briefly + * locked throughout. It is acceptable here because there are only a few fixed file names, each built + * once per process, and the function never re-enters the map, so the worst case is one thread briefly * serialising behind another. It would not be acceptable if this map grew keys at runtime. */ fun of(ctx: Context, fileName: String): SharedPreferences = diff --git a/android/app/src/main/java/com/noop/data/StandardHrMapping.kt b/android/app/src/main/java/com/noop/data/StandardHrMapping.kt new file mode 100644 index 0000000000..7db4f87542 --- /dev/null +++ b/android/app/src/main/java/com/noop/data/StandardHrMapping.kt @@ -0,0 +1,43 @@ +package com.noop.data + +import com.noop.protocol.StandardHrContact +import org.json.JSONObject + +/** Durable mapping for standard-BLE contact readings; legacy HR rows have no companion event. */ +object StandardHrMapping { + const val CONTACT_EVENT_KIND = "STANDARD_HR_CONTACT" + + /** + * Should this reading record a contact event, given the last one recorded? + * + * Contact is a STATE, not a measurement: it changes when the strap goes on or comes off, a handful of + * times a day, while the standard 0x2A37 stream arrives at ~1 Hz. Writing one row per reading stored + * ~86,400 rows a day per device to say the same thing 86,390 times, into a local-first database, for a + * read side that reconstructs "the value at or before ts" and therefore only ever needed the changes. + * + * `previous == null` records, so every session opens with its starting state and a reader never has to + * assume one. Twin of Swift `StandardHRMapping.shouldRecordContact`. + */ + fun shouldRecordContact(previous: StandardHrContact?, current: StandardHrContact): Boolean = + previous != current + + fun contactEvent(ts: Long, contact: StandardHrContact): EventEntry = EventEntry( + ts = ts, + kind = CONTACT_EVENT_KIND, + payloadJSON = "{\"contact\":\"${contact.storageValue}\"}", + ) + + /** + * Parse a persisted [CONTACT_EVENT_KIND] [EventRow.payloadJSON]. Throws [org.json.JSONException] + * (or [IllegalArgumentException] for an unknown value) so a parse failure is not the same as + * "no contact event" — absence is an empty query, not a skipped row. + */ + fun contactSample(row: EventRow): StandardHrContactSample { + val raw = JSONObject(row.payloadJSON).getString("contact") + val contact = StandardHrContact.fromStorageValue(raw) + ?: throw IllegalArgumentException("unknown STANDARD_HR_CONTACT value: $raw") + return StandardHrContactSample(row.ts, contact) + } +} + +data class StandardHrContactSample(val ts: Long, val contact: StandardHrContact) diff --git a/android/app/src/main/java/com/noop/data/StorageFootprint.kt b/android/app/src/main/java/com/noop/data/StorageFootprint.kt new file mode 100644 index 0000000000..ebe90fc5f1 --- /dev/null +++ b/android/app/src/main/java/com/noop/data/StorageFootprint.kt @@ -0,0 +1,91 @@ +package com.noop.data + +import androidx.sqlite.db.SimpleSQLiteQuery +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * #1911: estimated payload bytes per decoded stream table. Swift twin: `WhoopStore.storageByteEstimates`, + * and meta.json's `row_bytes` block beside `rows`. + * + * Rows alone cannot say which table holds a large store. `ppgWaveformSample` stores a BLOB whose size + * varies, and it is both the table a per-second row model misprices worst and the one this question is + * usually about — so the estimate MEASURES the blob rather than assuming a fixed row width. + * + * A hand-written class over [WhoopDatabase] rather than a method on [WhoopRepository], following + * [com.noop.push.PushDao]: the repository takes a DAO and a transactor precisely so its writes stay + * testable without a Room runtime, and raw per-table SQL needs the open helper. Keeping it out preserves + * that boundary instead of quietly widening it for a diagnostic. + */ +internal class StorageFootprint(private val db: WhoopDatabase) { + + /** + * Payload bytes per table, keyed exactly as `WhoopRepository.storageRowCounts` keys, so a maintainer + * comparing meta.json across platforms is comparing the same two maps. + * + * `typeof()` at runtime rather than the declared type, and byte length via `CAST(... AS BLOB)` rather + * than `length()`: SQLite is dynamically typed, so a column declared INTEGER can hold text, and + * `length()` on text counts CHARACTERS while the store spends bytes. The same shape + * `PushSnapshotPreflight.rowEstimateExpression` uses, without its JSON-expansion factors — this + * measures the database, not a snapshot of it. + * + * Sampled over [sampleRows] rows and multiplied by the count, never scanned: a full sum over a + * multi-gigabyte store is exactly the read this diagnostic exists to help someone avoid. The limit is + * clamped because SQLite reads a NEGATIVE limit as no limit at all, which would full-scan every table + * — the precise cost the sampling avoids, reached by a number that reads like it asks for less work. + * + * Payload only: no index pages, no page slack, no WAL. Read it against `db_bytes`, not as a second + * file size. An unreadable or empty table is omitted rather than reported as zero, matching the row + * counts — absent means "nothing to attribute here", which is not a measured zero. + * + * Suspending, and explicitly on IO. The callers launch the storage probe on the UI scope (the Test + * Centre screen says so in its own doc), and Room's suspend DAO methods hop to its query executor for + * free — a raw-query helper like this one does not, so without the switch these PRAGMA and AVG reads + * would run on the main thread, over the multi-gigabyte store this diagnostic exists for. + */ + suspend fun byteEstimates(rowCounts: Map, sampleRows: Int = 500): Map = + withContext(Dispatchers.IO) { estimateBlocking(rowCounts, sampleRows) } + + private fun estimateBlocking(rowCounts: Map, sampleRows: Int): Map = runCatching { + val raw = db.openHelper.readableDatabase + val limit = maxOf(1, sampleRows) + val out = mutableMapOf() + for ((key, table) in STORAGE_TABLES) { + val rows = rowCounts[key] ?: continue + if (rows <= 0) continue + val cols = mutableListOf() + raw.query(SimpleSQLiteQuery("PRAGMA table_info(`$table`)")).use { c -> + val i = c.getColumnIndex("name") + if (i >= 0) while (c.moveToNext()) cols.add(c.getString(i)) + } + if (cols.isEmpty()) continue + val terms = cols.joinToString(" + ") { col -> rowSizeTerm(col) } + val sql = "SELECT AVG(n) FROM (SELECT ($terms) AS n FROM `$table` LIMIT $limit)" + raw.query(SimpleSQLiteQuery(sql)).use { c -> + if (c.moveToFirst() && !c.isNull(0)) out[key] = Math.round(c.getDouble(0) * rows).toInt() + } + } + out.toMap() + }.getOrDefault(emptyMap()) + + internal companion object { + /** One column's contribution to a row's payload. Byte-true for text and blobs, nominal otherwise. */ + internal fun rowSizeTerm(column: String): String = + "(CASE WHEN `$column` IS NULL THEN 0 " + + "WHEN typeof(`$column`) IN ('text','blob') THEN length(CAST(`$column` AS BLOB)) " + + "ELSE 8 END)" + + /** + * The decoded stream tables and the key each reports under — Swift's `WhoopStore.rawTableKeys`, + * verbatim, and the same keys `WhoopRepository.storageRowCounts` uses. Adding a stream means + * adding it in all three, and the Apple side fails a test until it is. + */ + internal val STORAGE_TABLES: List> = listOf( + "hr" to "hrSample", "rr" to "rrInterval", "events" to "event", "battery" to "battery", + "spo2" to "spo2Sample", "skinTemp" to "skinTempSample", "resp" to "respSample", + "gravity" to "gravitySample", "steps" to "stepSample", "ppgHr" to "ppgHrSample", + "sleepState" to "sleepStateSample", "ppgWaveform" to "ppgWaveformSample", + "v18Aux" to "v18AuxSample", + ) + } +} diff --git a/android/app/src/main/java/com/noop/data/WhoopDao.kt b/android/app/src/main/java/com/noop/data/WhoopDao.kt index 818d9f7cfd..988401531b 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDao.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDao.kt @@ -8,6 +8,70 @@ import androidx.room.Transaction import androidx.room.Upsert import kotlinx.coroutines.flow.Flow +/** Kept as one compile-time constant so Room and the plain-JVM SQLite regression test execute the exact + * same statement. Swift's twin lives in WhoopStore.analysisFingerprint(). */ +internal const val ANALYSIS_FINGERPRINT_SQL = + "SELECT 'v2|' || " + + "'h' || (SELECT COUNT(*) FROM hrSample) || ':' || (SELECT COALESCE(MAX(ts), 0) FROM hrSample) || '|' || " + + "'p' || (SELECT COALESCE(MAX(rowid), 0) FROM ppgHrSample) || '|' || " + + "'r' || (SELECT COALESCE(MAX(rowid), 0) FROM rrInterval) || '|' || " + + "'x' || (SELECT COALESCE(MAX(rowid), 0) FROM respSample) || '|' || " + + "'g' || (SELECT COALESCE(MAX(rowid), 0) FROM gravitySample) || '|' || " + + "'s' || (SELECT COALESCE(MAX(rowid), 0) FROM sleepStateSample) || '|' || " + + "'e' || (SELECT COALESCE(MAX(rowid), 0) FROM event) || '|' || " + + "'o' || (SELECT COALESCE(MAX(rowid), 0) FROM spo2Sample) || '|' || " + + "'t' || (SELECT COALESCE(MAX(rowid), 0) FROM skinTempSample) || '|' || " + + "'z' || (SELECT COALESCE(MAX(rowid), 0) FROM stepSample)" + +/** Per-day, per-owner witness of every scored stream the HR fingerprint does NOT cover (#29). + * + * [ANALYSIS_FINGERPRINT_SQL] answers "did anything change anywhere"; the analyzeRecent per-day reuse cache + * asks "did THIS night's scored input change". A history offload does not commit its channels together — + * HR can land first and R-R, respiration or SpO2 minutes later, and an offloaded HR row duplicating a live + * one is ignored on conflict — so a night scored once from HR alone can gain its R-R with COUNT/MAX over + * hrSample completely unmoved. Keyed on HR alone the cache re-served that HRV-less scan for the rest of the + * process, a user-initiated refresh included. + * + * COUNT(*) is the load-bearing half: it moves when a backfill lands rows INSIDE a window already covered, + * which MAX(ts) alone would miss. Every arm is an index range walk over the same (deviceId, ts) key the HR + * fingerprint uses, materializing no rows. + * + * rrInterval is filtered exactly as [WhoopDao.rrIntervals] filters at read (the 0x6E SpO2-IBI duplicate and + * future-stamped beats), so the witness counts the beats that are actually scored. The literal 2 is + * RrSourceChannel.SPO2_IBI.code, pinned to the enum by RrChannelTest, for the same reason it is a literal + * there: a Room @Query is a compile-time constant string. + * + * sleepStateSample is in it because the band state is appended from the SAME v18 record at the same ts as + * HR: a re-offloaded record whose HR row is ignored on conflict still lands a new band row that the day is + * scored from. Its letter is 'b' (band), not 's', which [ANALYSIS_FINGERPRINT_SQL] already spends. + * + * The result is opaque and only ever compared to itself in memory, so it needs no byte identity with the + * Swift twin, `WhoopStore.dayStreamFingerprint`. Kept as one constant so every caller executes the exact + * same statement; unlike [ANALYSIS_FINGERPRINT_SQL] it carries :deviceId/:from/:to binds, so a plain-JVM + * SQLite harness could not run it verbatim. Room's KSP verification is what checks it. */ +internal const val DAY_STREAM_FINGERPRINT_SQL = + "SELECT 's1|' || " + + "'p' || (SELECT COUNT(*) FROM ppgHrSample WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to) || " + + "':' || (SELECT COALESCE(MAX(ts), 0) FROM ppgHrSample WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to) || '|' || " + + "'r' || (SELECT COUNT(*) FROM rrInterval WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to " + + "AND (srcChannel IS NULL OR srcChannel <> 2) AND (tsSuspect IS NULL OR tsSuspect <> 1)) || " + + "':' || (SELECT COALESCE(MAX(ts), 0) FROM rrInterval WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to " + + "AND (srcChannel IS NULL OR srcChannel <> 2) AND (tsSuspect IS NULL OR tsSuspect <> 1)) || '|' || " + + "'x' || (SELECT COUNT(*) FROM respSample WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to) || " + + "':' || (SELECT COALESCE(MAX(ts), 0) FROM respSample WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to) || '|' || " + + "'o' || (SELECT COUNT(*) FROM spo2Sample WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to) || " + + "':' || (SELECT COALESCE(MAX(ts), 0) FROM spo2Sample WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to) || '|' || " + + "'g' || (SELECT COUNT(*) FROM gravitySample WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to) || " + + "':' || (SELECT COALESCE(MAX(ts), 0) FROM gravitySample WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to) || '|' || " + + "'z' || (SELECT COUNT(*) FROM stepSample WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to) || " + + "':' || (SELECT COALESCE(MAX(ts), 0) FROM stepSample WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to) || '|' || " + + "'t' || (SELECT COUNT(*) FROM skinTempSample WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to) || " + + "':' || (SELECT COALESCE(MAX(ts), 0) FROM skinTempSample WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to) || '|' || " + + "'b' || (SELECT COUNT(*) FROM sleepStateSample WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to) || " + + "':' || (SELECT COALESCE(MAX(ts), 0) FROM sleepStateSample WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to) || '|' || " + + "'e' || (SELECT COUNT(*) FROM event WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to) || " + + "':' || (SELECT COALESCE(MAX(ts), 0) FROM event WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to)" + /** * Data-access for the local store. Mirrors the GRDB reads/writes in WhoopStore * (StreamStore.swift, Reads.swift, MetricsCache.swift). @@ -124,10 +188,6 @@ interface WhoopDao : DeviceRegistryDao { @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertPpgWaveform(rows: List): List - /** RAW 5/MG IMU offload buffers (packed i16 BLOB). Idempotent by (deviceId, ts). (#423) */ - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insertRawImu(rows: List): List - /** * The remaining 5/MG v18 per-second fields, one compact blob per strap-second. Idempotent by * (deviceId, ts) — IGNORE keeps the FIRST-seen row, matching every other per-second stream, so a @@ -136,6 +196,19 @@ interface WhoopDao : DeviceRegistryDao { @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertV18Aux(rows: List): List + /** + * Bound the PPG waveform table to the newest [keep] rows for [deviceId] (#1911 rolling retention). + * Same shape as [pruneV18Aux] below, and deliberately the same NEWEST-N semantic rather than an + * age-based delete: see `WhoopRepository.PPG_WAVEFORM_RETENTION_ROWS`. Swift twin: the DELETE at the + * end of `WhoopStore.insert`. + */ + @Query( + "DELETE FROM ppgWaveformSample WHERE deviceId = :deviceId AND ts < " + + "(SELECT MIN(ts) FROM (SELECT ts FROM ppgWaveformSample WHERE deviceId = :deviceId " + + "ORDER BY ts DESC LIMIT :keep))", + ) + suspend fun prunePpgWaveform(deviceId: String, keep: Int) + /** * Bound the v18 aux table to the newest [keep] rows for [deviceId] (rolling retention, v31). Same * shape as [pruneRawImu] — this is instrumentation nothing reads yet, so it is capped rather than @@ -147,22 +220,6 @@ interface WhoopDao : DeviceRegistryDao { ) suspend fun pruneV18Aux(deviceId: String, keep: Int) - /** Bound the raw-IMU table to the newest [keep] rows for [deviceId] (rolling retention, #423). */ - @Query( - "DELETE FROM rawImuSample WHERE deviceId = :deviceId AND ts < " + - "(SELECT MIN(ts) FROM (SELECT ts FROM rawImuSample WHERE deviceId = :deviceId ORDER BY ts DESC LIMIT :keep))" - ) - suspend fun pruneRawImu(deviceId: String, keep: Int) - - /** RAW 5/MG IMU buffers in [from, to] (ascending), packed i16 BLOB. (#423) - * Intentionally dormant — zero callers, retained for the eventual cross-check (see [RawImuSampleEntity] - * CONSUMER STATUS, #978). Not dead code; do not delete. */ - @Query( - "SELECT * FROM rawImuSample WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to " + - "ORDER BY ts ASC LIMIT :limit" - ) - suspend fun rawImuSamples(deviceId: String, from: Long, to: Long, limit: Int): List - // MARK: - Server-derived caches (latest value wins) @Upsert @@ -267,6 +324,12 @@ interface WhoopDao : DeviceRegistryDao { if (provenance.isNotEmpty()) upsertScoreInputProvenance(provenance) } + @Query( + "DELETE FROM metricSeries WHERE deviceId = :deviceId AND day >= :from AND day <= :to " + + "AND `key` = :key" + ) + suspend fun deleteMetricSeriesKeyInRange(deviceId: String, from: String, to: String, key: String) + /** * Replace a computed scoring window atomically. If any score or provenance write fails, Room rolls * the whole transaction back, so an old score can never be labelled with a newer provider. VO₂max's @@ -281,6 +344,8 @@ interface WhoopDao : DeviceRegistryDao { dailyMetrics: List, metricPoints: List, provenance: List, + replaceMetricKeys: List = emptyList(), + replaceMetricSourceIds: List = listOf(deviceId), ) { // #1196: a scoring pass that produced NO computed daily rows must NOT wipe the persisted window. // That happens transiently during a reconnect+offload storm (a pass runs over a still-incomplete @@ -293,6 +358,9 @@ interface WhoopDao : DeviceRegistryDao { if (dailyMetrics.isEmpty()) return deleteDailyMetricsInRange(deviceId, from, to) deleteScoreInputProvenanceInRange(deviceId, from, to) + for (sourceId in replaceMetricSourceIds) { + for (key in replaceMetricKeys) deleteMetricSeriesKeyInRange(sourceId, from, to, key) + } upsertDailyMetrics(dailyMetrics) if (metricPoints.isNotEmpty()) upsertMetricSeries(metricPoints) if (provenance.isNotEmpty()) upsertScoreInputProvenance(provenance) @@ -462,6 +530,18 @@ interface WhoopDao : DeviceRegistryDao { ) suspend fun events(deviceId: String, from: Long, to: Long, limit: Int): List + @Query( + "SELECT * FROM event WHERE deviceId = :deviceId AND kind = :kind " + + "AND ts >= :from AND ts <= :to ORDER BY ts ASC LIMIT :limit" + ) + suspend fun eventsByKind( + deviceId: String, + kind: String, + from: Long, + to: Long, + limit: Int, + ): List + @Query( "SELECT * FROM battery WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to " + "ORDER BY ts ASC LIMIT :limit" @@ -486,6 +566,54 @@ interface WhoopDao : DeviceRegistryDao { ) suspend fun stepSamples(deviceId: String, from: Long, to: Long, limit: Int): List + /** One predecessor for a half-open physiological-cycle scan. Device-scoped by construction. */ + @Query( + "SELECT * FROM stepSample WHERE deviceId = :deviceId AND ts < :onset " + + "ORDER BY ts DESC LIMIT 1" + ) + suspend fun stepSampleBefore(deviceId: String, onset: Long): StepSample? + + /** Stable activity-class mode for the complete cycle; it must never be re-decided per page. */ + @Query( + "SELECT EXISTS(SELECT 1 FROM stepSample WHERE deviceId = :deviceId " + + "AND ts >= :onset AND ts < :endExclusive AND activityClass IS NOT NULL LIMIT 1)" + ) + suspend fun hasStepActivityClasses(deviceId: String, onset: Long, endExclusive: Long): Boolean + + /** PK-index seek: never aggregate-scan the whole cycle merely to find its first sample. */ + @Query( + "SELECT ts FROM stepSample WHERE deviceId = :deviceId " + + "AND ts >= :onset AND ts < :endExclusive ORDER BY ts ASC LIMIT 1" + ) + suspend fun firstStepTimestamp( + deviceId: String, + onset: Long, + endExclusive: Long, + ): Long? + + /** PK-index seek paired with [firstStepTimestamp]; warm candidate coverage stays O(log n). */ + @Query( + "SELECT ts FROM stepSample WHERE deviceId = :deviceId " + + "AND ts >= :onset AND ts < :endExclusive ORDER BY ts DESC LIMIT 1" + ) + suspend fun lastStepTimestamp( + deviceId: String, + onset: Long, + endExclusive: Long, + ): Long? + + /** Keyset page for [afterExclusive, endExclusive); avoids both OFFSET and the legacy 200k cap. */ + @Query( + "SELECT * FROM stepSample WHERE deviceId = :deviceId AND ts > :afterExclusive " + + "AND ts < :endExclusive ORDER BY ts ASC LIMIT :limit" + ) + suspend fun stepSamplesPage( + deviceId: String, + afterExclusive: Long, + endExclusive: Long, + limit: Int, + ): List + /** The strap's OWN banked band sleep_state (#175) in [from, to], ascending. Feeds the Deep Timeline * band-state track and the per-session grid the H7 re-onset confirm guard reads. */ @Query( @@ -506,6 +634,33 @@ interface WhoopDao : DeviceRegistryDao { ) suspend fun gravitySamples(deviceId: String, from: Long, to: Long, limit: Int): List + /** + * Which raw streams a device has ANY rows for in a window — the strap-capability question, answered + * without counting. + * + * EXISTS rather than COUNT on purpose. The question is presence, and every one of these tables is + * keyed `(deviceId, ts)`, so each subquery is an index seek that stops at the first row instead of + * walking a night's worth — a 4.0 night carries ~190k gravity rows and counting them to learn "yes" + * would be absurd. One statement so it is one round trip. + * + * Answers what no existing line could: a strap streaming live HR with no motion cannot produce a + * staged night or an auto-detected workout, and until now a reader had to infer that from the absence + * of other lines. Diagnostics-export only, like [rawSampleCountsByDevice]. Swift twin: + * `WhoopStore.streamPresence`. + */ + data class StreamPresence( + val hr: Boolean, val rr: Boolean, val gravity: Boolean, val steps: Boolean, + ) + + @Query( + "SELECT " + + "EXISTS(SELECT 1 FROM hrSample WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to) AS hr, " + + "EXISTS(SELECT 1 FROM rrInterval WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to) AS rr, " + + "EXISTS(SELECT 1 FROM gravitySample WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to) AS gravity, " + + "EXISTS(SELECT 1 FROM stepSample WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to) AS steps" + ) + suspend fun streamPresence(deviceId: String, from: Long, to: Long): StreamPresence + /** Raw biometric sample counts per device id in a window - see [rawSampleCountsByDevice]. */ data class DeviceSampleCount(val deviceId: String, val total: Int) @@ -550,6 +705,12 @@ interface WhoopDao : DeviceRegistryDao { ) suspend fun dailyMetricsRange(deviceId: String, from: String, to: String): List + @Query( + "SELECT * FROM dailyMetric WHERE deviceId = :deviceId AND day >= :from AND day <= :to " + + "ORDER BY day ASC" + ) + fun dailyMetricsRangeFlow(deviceId: String, from: String, to: String): Flow> + /** * Delete a source's cached daily rows whose day-key is in [from, to] (inclusive, yyyy-MM-dd * lexicographic = chronological). The #277 local-day re-bucketing migration uses this to drop the @@ -656,6 +817,17 @@ interface WhoopDao : DeviceRegistryDao { to: String, ): List + @Query( + "SELECT * FROM metricSeries WHERE deviceId = :deviceId AND key = :key AND day >= :from AND day <= :to " + + "ORDER BY day ASC" + ) + fun metricSeriesFlow( + deviceId: String, + key: String, + from: String, + to: String, + ): Flow> + /** Distinct metric keys present for a device, sorted ascending (Swift metricKeys, v9). */ @Query("SELECT DISTINCT key FROM metricSeries WHERE deviceId = :deviceId ORDER BY key ASC") suspend fun metricKeys(deviceId: String): List @@ -941,6 +1113,10 @@ interface WhoopDao : DeviceRegistryDao { // #836: max raw-HR timestamp across all devices. Paired with countHr() as a cheap whole-history change // fingerprint so the 15-min idle rescore can skip when nothing new has landed (COALESCE → 0 when empty). @Query("SELECT COALESCE(MAX(ts), 0) FROM hrSample") suspend fun maxHrTs(): Long + /** Complete raw-analysis fingerprint. A sleep offload can add gravity/RR after HR, so the whole-pass + * gate must move when any scoring input changes, not only when measured HR changes. */ + @Query(ANALYSIS_FINGERPRINT_SQL) + suspend fun analysisFingerprint(): String // #1005: per-day (device + window) HR fingerprint — row count + newest ts — for analyzeRecent's per-day // reuse cache. Cheap COUNT/MAX aggregate over the (deviceId, ts) index, never a row fetch; mirrors Swift // WhoopStore.hrFingerprint(deviceId:from:to:). COALESCE(MAX) → 0 for an empty window. @@ -948,6 +1124,11 @@ interface WhoopDao : DeviceRegistryDao { suspend fun countHrInWindow(deviceId: String, from: Long, to: Long): Int @Query("SELECT COALESCE(MAX(ts), 0) FROM hrSample WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to") suspend fun maxHrTsInWindow(deviceId: String, from: Long, to: Long): Long + // #29: the same per-day (device + window) witness for every OTHER scored stream — see + // DAY_STREAM_FINGERPRINT_SQL. Without it a night whose R-R landed after its HR keyed identically to the + // HR-only scan it was scored from, and that HRV-less scan was re-served for the rest of the process. + @Query(DAY_STREAM_FINGERPRINT_SQL) + suspend fun dayStreamFingerprint(deviceId: String, from: Long, to: Long): String @Query("SELECT COUNT(*) FROM rrInterval") suspend fun countRr(): Int @Query("SELECT COUNT(*) FROM event") suspend fun countEvents(): Int @Query("SELECT COUNT(*) FROM battery") suspend fun countBattery(): Int @@ -960,7 +1141,6 @@ interface WhoopDao : DeviceRegistryDao { @Query("SELECT COUNT(*) FROM ppgHrSample") suspend fun countPpgHr(): Int @Query("SELECT COUNT(*) FROM sleepStateSample") suspend fun countSleepState(): Int @Query("SELECT COUNT(*) FROM ppgWaveformSample") suspend fun countPpgWaveform(): Int - @Query("SELECT COUNT(*) FROM rawImuSample") suspend fun countRawImu(): Int @Query("SELECT COUNT(*) FROM v18AuxSample") suspend fun countV18Aux(): Int @Query("SELECT COUNT(*) FROM respSample") suspend fun countResp(): Int @Query("SELECT COUNT(*) FROM gravitySample") suspend fun countGravity(): Int @@ -1023,8 +1203,6 @@ interface WhoopDao : DeviceRegistryDao { @Query("DELETE FROM ppgWaveformSample WHERE ts < :minTs OR ts > :maxTs") suspend fun prunePpgWaveformByTs(minTs: Long, maxTs: Long): Int - @Query("DELETE FROM rawImuSample WHERE ts < :minTs OR ts > :maxTs") - suspend fun pruneRawImuByTs(minTs: Long, maxTs: Long): Int @Query("DELETE FROM v18AuxSample WHERE ts < :minTs OR ts > :maxTs") suspend fun pruneV18AuxByTs(minTs: Long, maxTs: Long): Int @@ -1046,4 +1224,31 @@ interface WhoopDao : DeviceRegistryDao { * (before [minTs]) AND computed (`-noop`), so an imported multi-year sleep history survives (v8.2.1). */ @Query("DELETE FROM sleepSession WHERE startTs > :maxTs OR (startTs < :minTs AND deviceId LIKE '%-noop')") suspend fun pruneSleepSessionByTs(minTs: Long, maxTs: Long): Int + + // MARK: - PRD-K2: persisted Coach conversation + + /** The full stored conversation, oldest first (by orderIndex). */ + @Query("SELECT * FROM coachMessage ORDER BY orderIndex ASC") + suspend fun coachMessages(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCoachMessagesRaw(rows: List) + + /** Wipe the entire stored conversation (the Coach toolbar's "Clear conversation" action, and the + * first half of a full replace). */ + @Query("DELETE FROM coachMessage") + suspend fun clearCoachMessages() + + /** + * Replace the ENTIRE stored conversation with [rows] in one transaction. Simpler and safer than + * incremental insert/update/delete bookkeeping across the several streaming-finalization call + * sites (a streamed reply mutates the same message's text repeatedly before settling); the table + * is capped at the caller's MAX_STORED_MESSAGES, so a full replace is always cheap. Byte-parity + * twin of Swift `WhoopStore.replaceCoachMessages`. + */ + @Transaction + suspend fun replaceCoachMessages(rows: List) { + clearCoachMessages() + if (rows.isNotEmpty()) insertCoachMessagesRaw(rows) + } } diff --git a/android/app/src/main/java/com/noop/data/WhoopDatabase.kt b/android/app/src/main/java/com/noop/data/WhoopDatabase.kt index facba4f159..d548503982 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDatabase.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDatabase.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +import com.noop.push.PushDao import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase @@ -49,11 +50,11 @@ import androidx.sqlite.db.SupportSQLiteDatabase LabMarkerRow::class, LiveSessionRow::class, PpgWaveformSampleEntity::class, - RawImuSampleEntity::class, V18AuxSampleEntity::class, AppleStepHour::class, + CoachMessageRow::class, ], - version = 34, + version = 37, // #775: ON so Room's KSP processor writes the generated schema (every table's exact `CREATE TABLE`, // columns in declaration order with affinity/NOT NULL/default, PK and indices) as JSON. That export // is what lets a plain JVM test — no device, no Robolectric — read Android's REAL schema and compare @@ -66,11 +67,14 @@ import androidx.sqlite.db.SupportSQLiteDatabase abstract class WhoopDatabase : RoomDatabase() { abstract fun whoopDao(): WhoopDao + /** Read-only, schema-neutral snapshots for the opt-in self-hosted push worker. */ + fun pushDao(): PushDao = PushDao(this) + companion object { const val DB_NAME = "noop_whoop.db" /** Room schema version — MUST equal the `@Database(version = …)` above. Surfaced in the backup * manifest (#1410) so an export states its schema. Bump both together on a migration. */ - const val SCHEMA_VERSION = 34 + const val SCHEMA_VERSION = 37 @Volatile private var instance: WhoopDatabase? = null @@ -544,6 +548,12 @@ abstract class WhoopDatabase : RoomDatabase() { * BLOB (2 bytes/sample, little-endian i16, [StreamPersistence.packPpgSamples]) rather than 24 scalar * rows. * + * Retention: `ppgWaveformSample` is CAPPED at [WhoopRepository.PPG_WAVEFORM_RETENTION_ROWS] rolling + * rows per device (#1911), the same shape `v18AuxSample` uses. The cap is NEWEST-N ROWS and never an + * age cutoff: v26 seconds are spread thin over months for a sporadic wearer, so dropping by + * wall-clock age would empty the table for exactly the user a future re-analysis needs most, and a + * waveform has no aggregate that survives it. Swift twin: the `v27-ppg-waveform` migration note. + * * CREATE TABLE only (no existing data touched), so already-offloaded raw streams survive. The SQL MUST * match Room's generated schema for [PpgWaveformSampleEntity] exactly: deviceId TEXT NOT NULL, ts * INTEGER NOT NULL, samples BLOB NOT NULL (all Kotlin non-null, no SQL DEFAULT), composite PRIMARY KEY @@ -567,9 +577,8 @@ abstract class WhoopDatabase : RoomDatabase() { } } - /** #423: the WHOOP 5/MG raw-IMU offload-capture table. Additive; GRDB twin is `v28-raw-imu` - * (Swift's next slot after v27-ppg-waveform), so the migration COUNTS stay aligned. Column order == - * [RawImuSampleEntity] field order, matching the GRDB schema's t.column(deviceId/ts/samples). */ + /** Historical #423 rolling cache. Its bounded, write-only rows are retired by MIGRATION_34_35 + * after session-owned IMU moves to the file-backed store. */ internal val RAW_IMU_MIGRATION_SQL: List = listOf( "CREATE TABLE IF NOT EXISTS `rawImuSample` (`deviceId` TEXT NOT NULL, " + "`ts` INTEGER NOT NULL, `samples` BLOB NOT NULL, PRIMARY KEY(`deviceId`, `ts`))", @@ -921,6 +930,49 @@ abstract class WhoopDatabase : RoomDatabase() { } } + internal val MIGRATION_34_35 = object : Migration(34, 35) { + override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("DROP TABLE IF EXISTS `rawImuSample`") } + } + + /** + * v35 -> v36: ADDITIVE, adds `dailyMetric.sleepHrOnly` (nullable INTEGER) so the Recovery Vitals + * card can say why HRV and resting HR are blank on a night staged from heart rate alone, rather + * than showing a bare "No data" that is indistinguishable from a failed sync (#1801). Nullable + * with no default and no backfill: existing rows stay null and read as "not known", which is + * honest — the flag is only knowable from a re-score, and a re-score writes it. + * + * Exposed as a constant so the migration test can pin its SHAPE without Room-testing, the same + * way [DAILY_SKIN_TEMP_ABSOLUTE_MIGRATION_SQL] is. + */ + internal val DAILY_SLEEP_HR_ONLY_MIGRATION_SQL: List = listOf( + "ALTER TABLE `dailyMetric` ADD COLUMN `sleepHrOnly` INTEGER", + ) + + internal val MIGRATION_35_36 = object : Migration(35, 36) { + override fun migrate(db: SupportSQLiteDatabase) { + for (stmt in DAILY_SLEEP_HR_ONLY_MIGRATION_SQL) db.execSQL(stmt) + } + } + + /** PRD-K2: persisted Coach conversation history (schema v37). Swift twin: WhoopStore + * Database.swift `v43-coach-messages` migration. Column order matches [CoachMessageRow] + * field declaration order so Room's generated `CREATE TABLE` shape agrees. */ + internal val MIGRATION_36_37 = object : Migration(36, 37) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """CREATE TABLE IF NOT EXISTS `coachMessage` ( + `id` TEXT NOT NULL, + `role` TEXT NOT NULL, + `text` TEXT NOT NULL, + `provider` TEXT NOT NULL, + `createdAt` INTEGER NOT NULL, + `orderIndex` INTEGER NOT NULL, + PRIMARY KEY(`id`) + )""" + ) + } + } + /** * Every migration the builder registers, as a VALUE rather than an argument list. * @@ -946,10 +998,10 @@ abstract class WhoopDatabase : RoomDatabase() { MIGRATION_18_19, MIGRATION_19_20, MIGRATION_20_21, MIGRATION_21_22, MIGRATION_22_23, MIGRATION_23_24, MIGRATION_24_25, MIGRATION_25_26, MIGRATION_26_27, MIGRATION_27_28, MIGRATION_28_29, MIGRATION_29_30, - MIGRATION_30_31, MIGRATION_31_32, MIGRATION_32_33, MIGRATION_33_34, + MIGRATION_30_31, MIGRATION_31_32, MIGRATION_32_33, MIGRATION_33_34, MIGRATION_34_35, MIGRATION_35_36, + MIGRATION_36_37, ) - private fun build(appContext: Context): WhoopDatabase = Room.databaseBuilder(appContext, WhoopDatabase::class.java, DB_NAME) // #1014: replace ONLY the corruption handling of the default open-helper. The diff --git a/android/app/src/main/java/com/noop/data/WhoopRepository.kt b/android/app/src/main/java/com/noop/data/WhoopRepository.kt index b0fe987961..f01abded41 100644 --- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt +++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt @@ -317,11 +317,17 @@ data class InsertCounts( val gravity: Int = 0, ) +data class StepTimestampCoverage(val firstTs: Long?, val lastTs: Long?) + +internal fun newlyInsertedStepTimestamps(timestamps: List, rowIds: List): List = + timestamps.zip(rowIds).mapNotNull { (ts, id) -> ts.takeIf { id != -1L } } + /** Monotonic event revisions for Test Centre repository-backed readouts. Counts move only after Room * reports at least one newly inserted relevant row; duplicate/no-op inserts leave them unchanged. */ internal data class ReadoutDataRevisions( val sleepSamples: Long = 0, val battery: Long = 0, + val steps: Long = 0, ) internal fun advanceReadoutDataRevisions( @@ -330,8 +336,35 @@ internal fun advanceReadoutDataRevisions( ): ReadoutDataRevisions = current.copy( sleepSamples = current.sleepSamples + if (inserted.hr > 0 || inserted.gravity > 0) 1 else 0, battery = current.battery + if (inserted.battery > 0) 1 else 0, + steps = current.steps + if (inserted.steps > 0) 1 else 0, ) +/** Process-local because the cycle cache is process-local too; keyed by device and UTC day. */ +internal class StepDataRevisionIndex { + private val utcDaySeconds = 86_400L + private var nextRevision = 0L + private val byOwnerDay = HashMap, Long>() + + @Synchronized + fun record(deviceId: String, stepTimestamps: List, insertedRows: Int) { + if (insertedRows <= 0) return + nextRevision++ + for (ts in stepTimestamps) { + byOwnerDay[deviceId to Math.floorDiv(ts, utcDaySeconds)] = nextRevision + } + } + + @Synchronized + fun signature(deviceId: String, onset: Long, endExclusive: Long): String { + if (endExclusive <= onset) return "" + val first = Math.floorDiv(onset, utcDaySeconds) + val last = Math.floorDiv(endExclusive - 1L, utcDaySeconds) + return (first..last).joinToString(",") { day -> + "$day:${byOwnerDay[deviceId to day] ?: 0L}" + } + } +} + /** * A compact snapshot of how much history each source holds, for the Data Sources "Freshness * Pipeline" card (PR#196). Counts only , no per-day rows leave the read. Port of macOS @@ -397,6 +430,12 @@ class WhoopRepository( suspend fun run(block: suspend () -> R): R } + /** PPG waveform rows banked since its retention sweep last ran, PER DEVICE, for the same reason as + * [v18AuxRowsSincePrune] below: the sweep is per device, so a shared counter would let one strap + * spend another's budget. A separate counter because the two caps differ and a batch commonly writes + * one table and not the other. Swift twin: `WhoopStore.ppgWaveformRowsSincePrune`. */ + private val ppgWaveformRowsSincePrune = mutableMapOf() + /** v18 aux rows banked since the retention sweep last ran, PER DEVICE — the sweep is per device too, * so a shared counter would let one strap spend another's budget. Only the single-threaded offload * path banks v18 rows, so a plain map is enough. Swift twin: `WhoopStore.v18AuxRowsSincePrune`. */ @@ -408,6 +447,7 @@ class WhoopRepository( val batteryRevision: StateFlow = _batteryRevision.asStateFlow() private val readoutRevisionLock = Any() private var readoutRevisions = ReadoutDataRevisions() + private val stepRevisionIndex = StepDataRevisionIndex() constructor(db: WhoopDatabase) : this( dao = db.whoopDao(), @@ -464,18 +504,24 @@ class WhoopRepository( // shipped constants. (#888) v18AuxRetentionRows: Int = V18_AUX_RETENTION_ROWS, v18AuxPruneEveryRows: Int = V18_AUX_PRUNE_EVERY_ROWS, + ppgWaveformRetentionRows: Int = PPG_WAVEFORM_RETENTION_ROWS, + ppgWaveformPruneEveryRows: Int = PPG_WAVEFORM_PRUNE_EVERY_ROWS, ): InsertCounts { if (streams.isEmpty) return InsertCounts() - val counts = transactor.run { + val result = transactor.run { insertWithinTransaction( streams = streams, deviceId = deviceId, v18AuxRetentionRows = v18AuxRetentionRows, v18AuxPruneEveryRows = v18AuxPruneEveryRows, + ppgWaveformRetentionRows = ppgWaveformRetentionRows, + ppgWaveformPruneEveryRows = ppgWaveformPruneEveryRows, ) } + val counts = result.counts publishReadoutRevisions(counts) + stepRevisionIndex.record(deviceId, result.insertedStepTimestamps, counts.steps) return counts } @@ -488,13 +534,24 @@ class WhoopRepository( } } + /** Bounded process-local cache witness; duplicate rows do not advance because InsertCounts is zero. */ + fun stepDataRevisionSignature(deviceId: String, onset: Long, endExclusive: Long): String = + stepRevisionIndex.signature(deviceId, onset, endExclusive) + /** All DAO writes for one decoded chunk share the Room transaction opened by [insert]. */ + private data class InsertResult( + val counts: InsertCounts, + val insertedStepTimestamps: List, + ) + private suspend fun insertWithinTransaction( streams: StreamBatch, deviceId: String, v18AuxRetentionRows: Int, v18AuxPruneEveryRows: Int, - ): InsertCounts { + ppgWaveformRetentionRows: Int, + ppgWaveformPruneEveryRows: Int, + ): InsertResult { val hrIds = if (streams.hr.isEmpty()) emptyList() else dao.insertHr(streams.hr.map { HrSample(deviceId, it.ts, it.bpm) }) val rrIds = if (streams.rr.isEmpty()) emptyList() else @@ -548,6 +605,19 @@ class WhoopRepository( it.burstIndex) }, ) + // #1911 rolling retention, amortised and best-effort on exactly the same terms as the v18-aux + // sweep below (see [PPG_WAVEFORM_RETENTION_ROWS] for why this is newest-N and not an age-based + // drop). Its own counter: a batch routinely writes one of these two tables and not the other. + val bankedPpg = (ppgWaveformRowsSincePrune[deviceId] ?: 0) + streams.ppgWaveform.size + ppgWaveformRowsSincePrune[deviceId] = bankedPpg + if (bankedPpg >= ppgWaveformPruneEveryRows) { + runCatching { dao.prunePpgWaveform(deviceId, ppgWaveformRetentionRows) } + .onSuccess { ppgWaveformRowsSincePrune[deviceId] = 0 } + // prunePpgWaveform is a suspend call, so a scope cancellation arrives here as a + // CancellationException that runCatching would otherwise swallow, leaving the caller + // running inside a cancelled coroutine. Same rethrow as the v18-aux sweep below. + .onFailure { if (it is kotlin.coroutines.cancellation.CancellationException) throw it } + } } // Every remaining v18 slot (v31), one compact blob per strap-second. Persist-only, same as @@ -561,9 +631,8 @@ class WhoopRepository( } if (rows.isNotEmpty()) { dao.insertV18Aux(rows) - // Rolling retention (the insertRawImu shape, #423) but AMORTISED. The delete finds the - // Nth-newest row by rank, so it walks up to [V18_AUX_RETENTION_ROWS] index entries; - // insertRawImu keeps 3,600 so that is free, this keeps 604,800 and an offload inserts once + // Rolling retention is amortised. The delete finds the Nth-newest row by rank, so it walks up + // to [V18_AUX_RETENTION_ROWS] index entries; this keeps 604,800 and an offload inserts once // per chunk. Swept once per [V18_AUX_PRUNE_EVERY_ROWS] rows instead, which keeps // newest-N-rows exactly (a time window would not — a sporadically-worn strap's rows span // far more than a week, and the census wants that). Counter is per device because the @@ -585,7 +654,7 @@ class WhoopRepository( } // OnConflictStrategy.IGNORE returns -1 for skipped (already-present) rows; count the inserts. - return InsertCounts( + val counts = InsertCounts( hr = hrIds.countInserted() + ppgHrIds.countInserted(), rr = rrIds.countInserted(), events = evIds.countInserted(), @@ -596,6 +665,10 @@ class WhoopRepository( resp = respIds.countInserted(), gravity = gravIds.countInserted(), ) + return InsertResult( + counts, + newlyInsertedStepTimestamps(streams.steps.map { it.ts }, stepIds), + ) } /** #836 — cheap whole-history raw-HR change fingerprint `"count:maxTs"`. The idle 15-min rescore (the @@ -603,12 +676,24 @@ class WhoopRepository( * moves it (count or maxTs), so a real change always rescores; mirrors Swift WhoopStore.hrFingerprint. */ suspend fun hrFingerprint(): String = "${dao.countHr()}:${dao.maxHrTs()}" + /** Complete cross-device scoring-input change detector. Unlike [hrFingerprint], this also moves when + * a trailing sleep-critical stream (especially gravity) lands after HR in the same history sync. */ + suspend fun analysisFingerprint(): String = dao.analysisFingerprint() + /** #1005 — per-day (device + window) HR fingerprint as (count, newestTs) for analyzeRecent's per-day * reuse cache. Cheap COUNT/MAX aggregate, never a row fetch; mirrors Swift * WhoopStore.hrFingerprint(deviceId:from:to:). */ suspend fun hrFingerprintWindow(deviceId: String, from: Long, to: Long): Pair = Pair(dao.countHrInWindow(deviceId, from, to), dao.maxHrTsInWindow(deviceId, from, to)) + /** #29 — the same per-day (device + window) witness for every OTHER stream analyzeDay scores: PPG-derived + * HR, R-R, respiration, SpO2, gravity, steps, skin temp and events. [hrFingerprintWindow] cannot see a + * channel that lands after HR, and an offload commits its channels independently, so keyed on HR alone + * the reuse cache re-served an HRV-less scan for the rest of the process. See + * DAY_STREAM_FINGERPRINT_SQL; mirrors Swift WhoopStore.dayStreamFingerprint. */ + suspend fun dayStreamFingerprint(deviceId: String, from: Long, to: Long): String = + dao.dayStreamFingerprint(deviceId, from, to) + // MARK: - Server-derived caches (latest value wins on conflict) suspend fun upsertDailyMetrics(days: List) = dao.upsertDailyMetrics(days) @@ -627,10 +712,31 @@ class WhoopRepository( dailyMetrics: List, metricPoints: List, provenance: List, + replaceMetricKeys: List = emptyList(), + replaceMetricSourceIds: List = listOf(deviceId), ) = dao.replaceComputedScoreWindow( - deviceId, from, to, dailyMetrics, metricPoints, provenance + deviceId, from, to, dailyMetrics, metricPoints, provenance, + replaceMetricKeys, replaceMetricSourceIds, ) + /** Computed-only daily rows; unlike daysMerged this can never substitute imported/calendar steps. */ + suspend fun computedDailyUnion(activeStrapId: String, from: String, to: String): List = + unionByDay(computedSourceIds(activeStrapId).map { dao.dailyMetricsRange(it, from, to) }) + + fun computedDailyUnionFlow(activeStrapId: String, from: String, to: String): Flow> = + unionDaysFlow(computedSourceIds(activeStrapId).map { dao.dailyMetricsRangeFlow(it, from, to) }) + + fun metricSeriesComputedUnionFlow( + activeStrapId: String, + key: String, + from: String, + to: String, + ): Flow> { + val flows = computedSourceIds(activeStrapId).map { dao.metricSeriesFlow(it, key, from, to) } + return if (flows.size == 1) flows[0] + else combine(flows) { rows -> mergeComputedSeriesUnion(rows.toList()) } + } + suspend fun scoreInputSource(deviceId: String, day: String, key: String): String? = dao.scoreInputSource(deviceId, day, key) @@ -795,7 +901,6 @@ class WhoopRepository( // The instrumentation streams, missing from this list since they landed (see the DAO note). deleted += dao.pruneSleepStateByTs(minTs, maxTs) deleted += dao.prunePpgWaveformByTs(minTs, maxTs) - deleted += dao.pruneRawImuByTs(minTs, maxTs) deleted += dao.pruneV18AuxByTs(minTs, maxTs) deleted += dao.pruneEventByTs(minTs, maxTs) deleted += dao.pruneBatteryByTs(minTs, maxTs) @@ -886,19 +991,31 @@ class WhoopRepository( suspend fun sessionMotion(deviceId: String, sessionStart: Long): List? = dao.sessionMotionJson(deviceId, sessionStart)?.let { decodeDoubleArray(it) } - /** Per-epoch MOTION series for each of [starts] (detected session start keys), keyed by start (#407). - * Motion is written ONLY under the computed ("-noop") source by the engine, so we read there; an - * imported-only night (no computed twin) has no motion (absent stays absent , an honest empty state, - * never a fabricated zero array). Does NOT resolve the night: the caller has already chosen the - * main-night GROUP and passes those blocks' starts. A start with no stored series is omitted. Mirrors - * iOS Repository.sessionMotions. */ - suspend fun sessionMotions(strapDeviceId: String, starts: List): Map> { - if (starts.isEmpty()) return emptyMap() - val computedId = computedDeviceId(strapDeviceId) + /** Per-epoch motion for the already-resolved [sessions], keyed by detected start (#407). + * + * The engine writes motion under a computed ("-noop") namespace. A selected session can belong to + * an older strap, however, while [activeStrapId] names the strap worn now. Resolve each start from its + * owning computed namespace first, then every registered WHOOP's computed namespace (active first, + * canonical last). This keeps old nights attached to their source across repeated strap switches and + * still finds a computed twin for an imported canonical session. A missing series remains absent. + */ + suspend fun sessionMotions( + activeStrapId: String, + sessions: List, + ): Map> { + if (sessions.isEmpty()) return emptyMap() + val fallbackIds = rawWhoopSourceIds(activeStrapId).map { "$it-noop" } val out = HashMap>() - for (start in starts) { - val m = dao.sessionMotionJson(computedId, start)?.let { decodeDoubleArray(it) } - if (!m.isNullOrEmpty()) out[start] = m + for (session in sessions) { + if (out.containsKey(session.startTs)) continue + val ownerComputed = if (session.deviceId.endsWith("-noop")) session.deviceId else "${session.deviceId}-noop" + for (sourceId in (listOf(ownerComputed) + fallbackIds).distinct()) { + val motion = dao.sessionMotionJson(sourceId, session.startTs)?.let { decodeDoubleArray(it) } + if (!motion.isNullOrEmpty()) { + out[session.startTs] = motion + break + } + } } return out } @@ -947,7 +1064,7 @@ class WhoopRepository( // MARK: - Reads - suspend fun hrSamples(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT) = + suspend fun hrSamplesForDevice(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT) = dao.hrSamples(deviceId, from, to, limit) /** #856: HR samples over an EXPLICIT id list, deduped by ts with earlier ids winning — the sample @@ -959,9 +1076,8 @@ class WhoopRepository( else mergeHrByTs(deviceIds.map { dao.hrSamples(it, from, to, limit) }) /** - * HR samples over the read-side UNION of the active strap id AND the canonical "my-whoop" (SPINE / - * #814 + HIGH-2), deduped by ts with the active strap winning. This is the Kotlin twin of the Swift - * [com.noop] Repository.hrSamples(from:to:) union overload. + * HR samples over every registered WHOOP plus canonical "my-whoop", deduped by timestamp with the + * active strap winning and archived straps retained for historical windows. * * #908: a strap re-added through the in-app device manager banks its LIVE raw under its OWN fresh id * (e.g. "whoop-"), NOT "my-whoop". A Today-curve / live-Effort read pinned to the hardcoded @@ -970,7 +1086,7 @@ class WhoopRepository( * A single-WHOOP install resolves [activeDeviceId] to "my-whoop" ⇒ ONE id ⇒ byte-identical read. */ suspend fun hrSamplesUnion(activeDeviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT): - List = mergeHrByTs(importedSourceIds(activeDeviceId).map { dao.hrSamples(it, from, to, limit) }) + List = mergeHrByTs(rawWhoopSourceIds(activeDeviceId).map { dao.hrSamples(it, from, to, limit) }) /** Raw measured HR only (no v26 PPG-derived union) for the raw-sensor diagnostic export. */ suspend fun rawHrSamples(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT) = @@ -1004,25 +1120,9 @@ class WhoopRepository( dao.v18AuxSamples(deviceId, from, to, limit) .map { V18AuxCodec.unpack(it.fields, it.ts) } - /** #423: persist a batch of decoded 5/MG raw-IMU offload buffers (one row per strap-second, packed i16 - * BLOB), then bound the table to the newest [RAW_IMU_RETENTION_ROWS] for the device. Comes from the - * deep-buffer capture seam, not the normal stream path, so it inserts directly (idempotent by ts). */ - suspend fun insertRawImu(deviceId: String, rows: List) { - if (rows.isEmpty()) return - dao.insertRawImu(rows) - dao.pruneRawImu(deviceId, RAW_IMU_RETENTION_ROWS) - } - - /** #423: raw 5/MG IMU buffers in [from, to] as the decoded i16 columns [ax…az,gx…gz] (100/axis). - * Intentionally dormant — zero callers, retained for the eventual cross-check (see [RawImuSampleEntity] - * CONSUMER STATUS, #978). Not dead code; do not delete. */ - suspend fun rawImuSamples(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT): - List> = - dao.rawImuSamples(deviceId, from, to, limit) - .map { it.ts to StreamPersistence.unpackImuColumns(it.samples) } /** Downsampled HR (mean bpm per [bucketSeconds]) for the strap, for the Today 24h trend chart. */ - suspend fun hrBuckets(deviceId: String, from: Long, to: Long, bucketSeconds: Long = 300L) = + suspend fun hrBucketsForDevice(deviceId: String, from: Long, to: Long, bucketSeconds: Long = 300L) = dao.hrBuckets(deviceId, from, to, bucketSeconds) /** #856: the same dedup over an EXPLICIT id list, so a workout can read its OWN recording strap @@ -1034,15 +1134,13 @@ class WhoopRepository( else mergeHrBucketsByStart(deviceIds.map { dao.hrBuckets(it, from, to, bucketSeconds) }) /** - * Downsampled HR buckets over the read-side UNION of the active strap id AND the canonical "my-whoop" - * (SPINE / #814 + HIGH-2), deduped by bucket start with the active strap winning. Kotlin twin of the - * Swift Repository.hrBuckets(from:to:bucketSeconds:) union overload. #908: keeps the Today HR curve - * pointed at whichever id the re-added strap actually banks under. Single-WHOOP install ⇒ one id ⇒ - * byte-identical read. + * Downsampled HR buckets over every registered WHOOP (active first, archived included, canonical + * last), deduped by bucket start with the active source winning. Kotlin twin of the raw-sample union. + * A legacy canonical-only install remains a single byte-identical DAO read. */ suspend fun hrBucketsUnion(activeDeviceId: String, from: Long, to: Long, bucketSeconds: Long = 300L): List = mergeHrBucketsByStart( - importedSourceIds(activeDeviceId).map { dao.hrBuckets(it, from, to, bucketSeconds) }, + rawWhoopSourceIds(activeDeviceId).map { dao.hrBuckets(it, from, to, bucketSeconds) }, ) /** @@ -1147,12 +1245,33 @@ class WhoopRepository( } } - suspend fun rrIntervals(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT) = + suspend fun rrIntervalsForDevice(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT) = dao.rrIntervals(deviceId, from, to, limit) + /** R-R beats over active strap + canonical history. Exact duplicate beats are removed with the + * active source winning; distinct beats sharing a timestamp remain intact. */ + suspend fun rrIntervalsUnion( + activeDeviceId: String, + from: Long, + to: Long, + limit: Int = DEFAULT_LIMIT, + ): List = mergeRrByIdentity( + rawWhoopSourceIds(activeDeviceId).map { dao.rrIntervals(it, from, to, limit) }, + ) + suspend fun events(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT) = dao.events(deviceId, from, to, limit) + /** Standard-BLE contact readings only; legacy HR rows have no companion event and stay absent. */ + suspend fun standardHrContacts( + deviceId: String, + from: Long, + to: Long, + limit: Int = DEFAULT_LIMIT, + ): List = dao.eventsByKind( + deviceId, StandardHrMapping.CONTACT_EVENT_KIND, from, to, limit, + ).map(StandardHrMapping::contactSample) + suspend fun batterySamples(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT) = dao.batterySamples(deviceId, from, to, limit) @@ -1165,6 +1284,25 @@ class WhoopRepository( suspend fun stepSamples(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT) = dao.stepSamples(deviceId, from, to, limit) + suspend fun stepSampleBefore(deviceId: String, onset: Long): StepSample? = + dao.stepSampleBefore(deviceId, onset) + + suspend fun hasStepActivityClasses(deviceId: String, onset: Long, endExclusive: Long): Boolean = + dao.hasStepActivityClasses(deviceId, onset, endExclusive) + + suspend fun stepTimestampCoverage(deviceId: String, onset: Long, endExclusive: Long): StepTimestampCoverage = + StepTimestampCoverage( + firstTs = dao.firstStepTimestamp(deviceId, onset, endExclusive), + lastTs = dao.lastStepTimestamp(deviceId, onset, endExclusive), + ) + + suspend fun stepSamplesPage( + deviceId: String, + afterExclusive: Long, + endExclusive: Long, + limit: Int, + ): List = dao.stepSamplesPage(deviceId, afterExclusive, endExclusive, limit) + /** * The strap's OWN band sleep_state samples (#175) in [from, to] as (ts, state) pairs, ascending. Feeds * the Deep Timeline band-state track and the per-session grid the H7 re-onset confirm guard reads. Empty @@ -1327,10 +1465,41 @@ class WhoopRepository( suspend fun respSamples(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT) = dao.respSamples(deviceId, from, to, limit) - suspend fun gravitySamples(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT) = + suspend fun gravitySamplesForDevice(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT) = dao.gravitySamples(deviceId, from, to, limit) - suspend fun sleepSessions(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT) = + /** Which raw streams this device has any rows for — see [WhoopDao.streamPresence]. Presence, not + * counts, so it is four index seeks that stop at the first row. Diagnostics-export only. */ + suspend fun streamPresence(deviceId: String, from: Long, to: Long) = + dao.streamPresence(deviceId, from, to) + + /** + * Gravity samples over the read-side union of the active strap id and canonical "my-whoop", + * deduped by timestamp with the active strap winning. A re-added strap banks live motion under its + * fresh device id while older/imported motion can remain under the canonical id; calibration reads + * need both histories just like [hrSamplesUnion]. A canonical active id still performs one unchanged + * DAO read. + */ + suspend fun gravitySamplesUnion( + activeDeviceId: String, + from: Long, + to: Long, + limit: Int = DEFAULT_LIMIT, + ): List = mergeGravityByTs( + rawWhoopSourceIds(activeDeviceId).map { dao.gravitySamples(it, from, to, limit) }, + ) + + /** Resolve raw WHOOP telemetry across every registered WHOOP, including archived straps. Registry + * order is stable (addedAt ASC); the currently worn strap wins overlaps and canonical imports lose + * overlaps. The explicitly active source is preserved even for another brand; unrelated registered + * devices from other brands are deliberately excluded from WHOOP-specific fallback reads. */ + private suspend fun rawWhoopSourceIds(activeDeviceId: String): List = + rawWhoopSourceIdsFor( + activeDeviceId, + dao.pairedDevices().filter { it.brand.equals("WHOOP", ignoreCase = true) }.map { it.id }, + ) + + suspend fun sleepSessionsForDevice(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT) = dao.sleepSessions(deviceId, from, to, limit) /** @@ -1349,13 +1518,15 @@ class WhoopRepository( val now = System.currentTimeMillis() / 1000L val lo = now - days * 86_400L val hi = now + 86_400L - // UNION active strap + canonical "my-whoop" (imported) and their computed siblings (#814/#1008), + // UNION every registered WHOOP (active first, including archived, canonical last) and their + // computed siblings, // de-duplicating identical (startTs, endTs) blocks recorded under both ids so a night present in // both namespaces doesn't double-weight the learner. Reading one id narrowed the night set vs iOS // after a strap re-add (the learner could cold-start to null where iOS returned a learned value). // Mirrors Swift Repository.habitualMidsleepSec (importedReadIds/computedReadIds + dedupBlocks). - val imported = dedupSleepBlocks(importedSourceIds(deviceId).flatMap { dao.sleepSessions(it, lo, hi, 4000) }) - val computed = dedupSleepBlocks(computedSourceIds(deviceId).flatMap { dao.sleepSessions(it, lo, hi, 4000) }) + val rawIds = rawWhoopSourceIds(deviceId) + val imported = dedupSleepBlocks(rawIds.flatMap { dao.sleepSessions(it, lo, hi, 4000) }) + val computed = dedupSleepBlocks(rawIds.map { "$it-noop" }.flatMap { dao.sleepSessions(it, lo, hi, 4000) }) val offsetSec = (java.util.TimeZone.getDefault().getOffset(System.currentTimeMillis()) / 1000).toLong() val blocks = (imported + computed).mapNotNull { s -> val start = s.effectiveStartTs @@ -1561,6 +1732,15 @@ class WhoopRepository( * the Phase-1 zeros. Keys mirror the Swift probe (TestCentreReport.storageProbe) so a maintainer * reads the same map from either platform. Best-effort: a read failure returns empty (the caller's * zeroed fallback stays an honest "unreadable"), never a fabricated figure. + * + * THE KEY SET IS A CROSS-PLATFORM CONTRACT. Apple pins it in `WhoopStoreTests.ReadTests` + * (`testStorageRowCountsNamesEveryAccumulatingTable`) against this exact list, so adding a table + * there fails loudly until both sides agree. There is no equivalent pin on this side: these counts + * need a DAO, and the JVM unit tests have no in-memory Room, so a key added HERE and not there goes + * unnoticed. Add it to `WhoopStore.rawTableKeys` in the same change - the Apple probe reads that list + * for both its total and its breakdown, so a table missing from it is invisible in the footprint, + * which is how ppgWaveform - the only blob table, and the one a row-count model misprices worst - + * stayed unreported on Apple until #1911 asked which table was large. */ suspend fun storageRowCounts(): Map = runCatching { mapOf( @@ -1570,9 +1750,9 @@ class WhoopRepository( "resp" to dao.countResp(), "gravity" to dao.countGravity(), // The rest of the accumulating decoded raw streams, so the Test-Centre footprint counts ALL of // them (keep in sync with Swift storageStats / TimestampHeal's raw-table list). ppgHr/ppgWaveform - // /rawImu can each be large. + // /v18Aux can each be large. "ppgHr" to dao.countPpgHr(), "sleepState" to dao.countSleepState(), - "ppgWaveform" to dao.countPpgWaveform(), "rawImu" to dao.countRawImu(), + "ppgWaveform" to dao.countPpgWaveform(), "v18Aux" to dao.countV18Aux(), ) }.getOrDefault(emptyMap()) @@ -1700,7 +1880,8 @@ class WhoopRepository( computed = computedSourceIds(deviceId).reversed().flatMap { dao.sleepSessions(it, from, to, limit) }, ) - /** ALL imported sleep BLOCKS across the active∪canonical union (#814/#1008), keeping every session + /** ALL imported sleep BLOCKS across every registered WHOOP (active first, archived included, + * canonical last), keeping every session * per day (a nap + a main night both survive) and dropping only EXACT-duplicate (startTs, endTs) * blocks recorded under both union ids , active strap FIRST so it keeps the surviving copy. The * Sleep tab's chevron walk reads this instead of the single canonical id, so a night recorded under @@ -1708,27 +1889,30 @@ class WhoopRepository( * caller's, exactly as before). Mirrors Swift Repository.unionSleepSessions. */ suspend fun sleepSessionsUnion(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT): List = - dedupSleepBlocks(importedSourceIds(deviceId).flatMap { dao.sleepSessions(it, from, to, limit) }) + dedupSleepBlocks(rawWhoopSourceIds(deviceId).flatMap { dao.sleepSessions(it, from, to, limit) }) /** The COMPUTED ("-noop") twin of [sleepSessionsUnion]: all computed sleep blocks across the computed * union ids, exact-duplicate blocks dropped (active's computed sibling first). Mirrors Swift * Repository.unionComputedSleepSessions. */ suspend fun computedSleepSessionsUnion(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT): - List = - dedupSleepBlocks(computedSourceIds(deviceId).flatMap { dao.sleepSessions(it, from, to, limit) }) + List { + val ids = rawWhoopSourceIds(deviceId).map { "$it-noop" } + return dedupSleepBlocks(ids.flatMap { dao.sleepSessions(it, from, to, limit) }) + } - /** Workouts over the read-side UNION of the active strap id AND the canonical "my-whoop" (#814 twin of - * [hrSamplesUnion] / [sleepSessionsUnion]): a re-added / newly-paired strap owns "whoop-" while + /** Workouts over every registered WHOOP (active first, archived retained) plus canonical "my-whoop", + * matching [hrSamplesUnion] / [sleepSessionsUnion]. A re-added strap owns "whoop-" while * imports + prior data live under "my-whoop", so a read pinned to a SINGLE id strands the other's * workouts — the Workouts screen then reads empty while Data Sources (which queries "my-whoop") shows * them (#28). Exact-duplicate rows are dropped on the (startTs, sport) natural key, active-strap-first. */ suspend fun workoutsUnion(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT): List = - dedupWorkoutsByKey(importedSourceIds(deviceId).flatMap { dao.workouts(it, from, to, limit) }) + dedupWorkoutsByKey(rawWhoopSourceIds(deviceId).flatMap { dao.workouts(it, from, to, limit) }) /** The COMPUTED ("-noop") twin of [workoutsUnion] for detected workouts (the engine writes detected * sessions under "-noop"), across the computed union ids. */ suspend fun detectedWorkoutsUnion(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT): List = - dedupWorkoutsByKey(computedSourceIds(deviceId).flatMap { dao.workouts(it, from, to, limit) }) + dedupWorkoutsByKey(rawWhoopSourceIds(deviceId).map { "$it-noop" } + .flatMap { dao.workouts(it, from, to, limit) }) /** Cached daily metrics for the inclusive day range [from, to] (YYYY-MM-DD), oldest first. */ suspend fun dailyMetrics(deviceId: String, from: String, to: String): List = @@ -1807,7 +1991,24 @@ class WhoopRepository( // Book / any resolver read agrees with the mergeDaily dashboards instead of re-surfacing the // schedule target the merge already rejects. val perCandidate = candidates.map { it to resolvedRows(it, from, to) } - return MetricSeriesResolution(preferredSource, candidates, resolveFirstWins(perCandidate)) + val points = resolveFirstWins(perCandidate) + // #1705: skin_temp is bimodal — a CSV import writes ABSOLUTE °C into skinTempDevC while the + // computed pipeline writes a baseline DEVIATION, and the candidate list unions the imported and + // computed identities, so first-wins can resolve older days to one kind and recent days to the + // other. Every consumer aggregates these points (Compare's min/max and its min-max chart + // normalisation, Lab Book), and that is arithmetic across two scales. Reduce to the newest + // entry's kind HERE, at the one place the candidates are resolved, rather than at each screen. + if (key == "skin_temp") { + val keep = com.noop.analytics.SkinTempDisplay.dominantKind(points.map { it.value }) + if (keep != null) { + return MetricSeriesResolution( + preferredSource, + candidates, + points.filter { com.noop.analytics.SkinTempDisplay.kind(it.value) == keep }, + ) + } + } + return MetricSeriesResolution(preferredSource, candidates, points) } /** @@ -1961,16 +2162,57 @@ class WhoopRepository( /** Default row cap on range reads. Matches the Swift call sites' bounded scans. */ const val DEFAULT_LIMIT = 100_000 - /** #423: rolling retention for the raw-IMU capture table (1 row/strap-second, ~1.2 KB each). One - * hour ≈ 3600 rows ≈ 4 MB caps the table hard, so an enabled capture can never balloon the DB - * during a multi-day offload replay. Instrument-first bounded window; nothing consumes it yet. */ - const val RAW_IMU_RETENTION_ROWS = 3600 + /** + * #1911: rolling retention for the v27 PPG waveform table (Swift twin + * `WhoopStore.ppgWaveformRetentionRows`). Previously the only UNBOUNDED blob table, and it carries + * by far the largest PER-ROW cost of any decoded stream: ~120 B against ~30 B for a scalar row. + * + * It is NOT the store's fastest-growing table, and this note must not be read as saying so. v26 + * runs only in optical windows, roughly 28,800 rows/day by #1911's own figures, where `rrInterval` + * banks ~100,000/day and remains the higher-volume table by bytes. Capping this one bounds the + * worst row, not the bulk of #1911's ~93 MB/day. + * + * A NEWEST-N-ROWS CAP, DELIBERATELY NOT A TIME-WINDOW DROP. #1911 proposes "dropped after the hot + * window", calling the waveform "diagnostic-only". That framing is wrong: the migration note on + * `ppgWaveformSample` is the authority, and these rows are kept so a better estimator, + * HRV-from-PPG, or a waveform viewer can later run over the ORIGINAL samples rather than the + * derived bpm. Deleting by wall-clock age would empty the table for exactly the user such an + * estimator needs most (a sporadic wearer, whose v26 seconds are spread thin over months), and a + * waveform has no aggregate that survives it. Newest-N bounds the bytes while always leaving a + * full working set — the same trade [V18_AUX_RETENTION_ROWS] below makes. + * + * 604,800 = 7 × 86,400, matching the aux cap's "week of strap-seconds" semantic and #1911's own + * 7-day hot window. The ceiling is larger than aux's because the row is: ~120 B/row (a 48 B + * packed-i16 blob for 24 samples plus row and primary-key-index overhead) gives ~70 MB per device + * against ~50 MB for aux. That is the bound worth quoting; the wall-clock + * span is longer than the arithmetic suggests, because v26 only runs in optical windows. At #1911's + * ~28,800 rows/day the cap holds about three weeks of typical wear, and proportionally more for a + * sporadic wearer, which is exactly the population an age-based cutoff would have emptied. Retuning + * is one constant with no migration. + */ + const val PPG_WAVEFORM_RETENTION_ROWS = 604_800 + + /** Rows to bank before sweeping `ppgWaveformSample` again — same amortisation and magnitude as + * [V18_AUX_PRUNE_EVERY_ROWS] below, for the same reason: the sweep walks up to + * [PPG_WAVEFORM_RETENTION_ROWS] index entries, so running it per insert batch is the cost. The + * table may sit this many rows (plus the crossing batch) above the cap, roughly a MB against its + * ~70 MB bound. Swift twin: `WhoopStore.ppgWaveformPruneEveryRows`. + * + * WHAT THIS BUDGET DOES NOT GUARANTEE, and why the cap above is stated as a size and not a "hard + * ceiling": the counter is in-memory and per repository instance, so process death resets it. The + * sweep is the ONLY thing enforcing retention on this table (the `*ByTs` deletes belong to the + * timestamp heal, not to retention), so a repository that never banks this many rows in one + * process lifetime never sweeps. Not a concern for the normal shape, since the budget accumulates + * across every batch of a session and one night's offload banks ~28,800 rows, but a repository fed + * only short bursts between app kills can drift above the cap indefinitely. + * [V18_AUX_PRUNE_EVERY_ROWS] has the identical property; forcing a sweep once per session would + * close it for both, and belongs in a change that covers both. */ + const val PPG_WAVEFORM_PRUNE_EVERY_ROWS = 10_000 /** * v31: rolling retention for the v18 aux-slot table (Swift twin `WhoopStore.v18AuxRetentionRows`). * - * [RAW_IMU_RETENTION_ROWS] is the closest precedent — raw instrumentation banked as a blob, capped - * rather than unbounded — and the same reasoning applies: nothing reads these rows yet, so a cap + * Raw instrumentation is capped rather than unbounded. Nothing reads these rows yet, so a cap * is far cheaper to RELAX later than to impose once users have a year of history. Unbounded, this * table is the one genuinely new source of row growth in v31; the four named columns only WIDEN * rows that were already being written (~14 B on a gravity/skinTemp/sleepState row that exists @@ -2005,10 +2247,10 @@ class WhoopRepository( * The IMPORTED daily-source ids to read for an [activeDeviceId]: the UNION of the active strap id * AND the canonical legacy "my-whoop", active FIRST (so a per-day pick takes the active/live row). * SPINE / #814 + HIGH-2. A re-added strap writes live data under its fresh id while the WHOOP-export - * import path ([com.noop.ingest.WhoopCsvImporter]) keeps writing under the canonical "my-whoop" - * (never drifting), so reading only the active id orphans the import. A single-WHOOP install resolves - * to "my-whoop" only ⇒ one id, byte-identical reads. Companion form so [com.noop.ui.FusionDayAdapter] - * (an object) and the instance reads share ONE definition. + * import path ([com.noop.ingest.WhoopCsvImporter]) keeps writing under the canonical "my-whoop" + * (never drifting), so reading only the active id orphans the import. A single-WHOOP install resolves + * to "my-whoop" only ⇒ one id, byte-identical reads. Companion form so [com.noop.ui.FusionDayAdapter] + * (an object) and the instance reads share ONE definition. */ fun importedSourceIdsFor(activeDeviceId: String): List = if (activeDeviceId == WHOOP_SOURCE) listOf(WHOOP_SOURCE) @@ -2019,6 +2261,19 @@ class WhoopRepository( fun computedSourceIdsFor(activeDeviceId: String): List = importedSourceIdsFor(activeDeviceId).map { "$it-noop" } + /** Pure ordering contract shared with Swift through Tools/parity_cases/device_raw_sources.json. */ + fun rawWhoopSourceIdsFor(activeDeviceId: String, registeredWhoopIds: List): List = + (listOf(activeDeviceId) + + registeredWhoopIds.filter { it != activeDeviceId && it != WHOOP_SOURCE } + + WHOOP_SOURCE).distinct() + + /** Computed namespaces to probe for a session's motion, owner first and then the current + * active + canonical union. [sessionOwnerId] may already be a computed id. */ + fun motionSourceIdsFor(activeDeviceId: String, sessionOwnerId: String): List { + val ownerComputed = if (sessionOwnerId.endsWith("-noop")) sessionOwnerId else "$sessionOwnerId-noop" + return (listOf(ownerComputed) + computedSourceIdsFor(activeDeviceId)).distinct() + } + /** Pick the winner among per-source LATEST rows ([computedSourceIdsFor] order, active-strap * first): the strictly newest day wins; a shared newest day keeps the FIRST seen (the active * strap) — byte-identical to what `mergeComputedSeriesUnion(...).lastOrNull()` yields on the @@ -2306,6 +2561,10 @@ class WhoopRepository( remMin = if (sleepFromFiller) filler.remMin else winner.remMin, lightMin = if (sleepFromFiller) filler.lightMin else winner.lightMin, disturbances = if (sleepFromFiller) filler.disturbances else winner.disturbances, + // Part of the SLEEP GROUP (#1801): it describes how the stage figures above were derived, + // so it moves with them. `copy()` would leave the WINNER's flag in place while the stages + // came from the filler — captioning one row's hypnogram with another row's staging. + sleepHrOnly = if (sleepFromFiller) filler.sleepHrOnly else winner.sleepHrOnly, spo2Red = if (rawSpo2FromFiller) filler.spo2Red else winner.spo2Red, spo2Ir = if (rawSpo2FromFiller) filler.spo2Ir else winner.spo2Ir, // Independent columns: each stands alone, so a plain per-column fill is safe. @@ -2339,6 +2598,36 @@ class WhoopRepository( return byTs.values.sortedBy { it.ts } } + /** Merge R-R lists with source priority matching [mergeHrByTs]. R-R has multiple legitimate + * beats at one timestamp, so its identity is the storage key excluding deviceId, not timestamp + * alone. The first (active) list wins only an exact duplicate. */ + internal fun mergeRrByIdentity(lists: List>): List { + if (lists.size == 1) return lists[0] + data class BeatKey(val ts: Long, val rrMs: Int, val seq: Int) + val byBeat = LinkedHashMap() + for (list in lists) for (beat in list) { + byBeat.putIfAbsent(BeatKey(beat.ts, beat.rrMs, beat.seq), beat) + } + return byBeat.values.sortedWith( + compareBy { it.ts } + .thenBy { it.seq } + .thenBy { it.rrMs } + .thenBy { it.ord }, + ) + } + + /** + * Merge gravity sample lists into one time-ordered stream, deduped by timestamp with the FIRST + * list (the active strap) winning on a tie. A single-id read is returned unchanged, matching the + * pre-union path and [mergeHrByTs]. + */ + internal fun mergeGravityByTs(lists: List>): List { + if (lists.size == 1) return lists[0] + val byTs = LinkedHashMap() + for (list in lists) for (s in list) byTs.putIfAbsent(s.ts, s) + return byTs.values.sortedBy { it.ts } + } + /** * Merge HR bucket lists (the active-id ∪ canonical union) into one time-ordered stream, deduped * by bucket start with the FIRST list (the active strap) winning on a tie. Kotlin twin of the @@ -2542,7 +2831,15 @@ class WhoopRepository( * (ryanbr/noop#241): a sparse import (no stage data on ANY of its sessions that day) must NOT clobber * a computed day that HAS stage data — otherwise a stage-less WHOOP/Apple/HC re-import blanks the * stage breakdown for a night the strap fully staged. Days where the import carries stages, or where - * neither side does, keep the imported-wins rule. Mirrors WhoopStore.SleepMerge (SleepMergeTests). */ + * neither side does, keep the imported-wins rule. Mirrors WhoopStore.SleepMerge (SleepMergeTests). + * + * Richness is a RANK, not a yes/no ([richness]): 2 = stages covering the span they claim, 1 = + * stages present but HOLED, 0 = none — and the computed day wins only when it OUT-RANKS the + * import. Collapsing 1 and 0 gives back the original presence rule exactly, so this is a strict + * generalisation: every case #241 decided it still decides the same way. What changes is the pair + * the old rule could not express — a holed import (a device hypnogram assembled from records that + * arrived incomplete: many segments, a fraction of the span) against a COMPLETE computed night, + * which used to go to the import and now goes to the night that actually covers itself. */ internal fun mergeSleepRichness( imported: List, computed: List, @@ -2553,8 +2850,8 @@ class WhoopRepository( val out = ArrayList(imported.size + computed.size) for ((day, imp) in importedByDay) { val comp = computedByDay[day] - if (comp != null && imp.none { hasStages(it) } && comp.any { hasStages(it) }) { - out.addAll(comp) // richer computed day survives a stage-less import + if (comp != null && dayRichness(comp) > dayRichness(imp)) { + out.addAll(comp) // richer computed day survives a poorer import } else { out.addAll(imp) // imported wins its day (unchanged rule) } @@ -2569,6 +2866,23 @@ class WhoopRepository( val json = s.stagesJSON?.trim() ?: return false return json.isNotEmpty() && json != "[]" } + + /** How much of a night this session's stages actually describe: 2 = covers its span, 1 = present + * but HOLED, 0 = none. A timeline whose coverage cannot be measured (the imported minute-dict + * shape, which has no timestamps) is never holed, so it ranks 2 — imports keep being judged on + * presence exactly as they always were, and this gate cannot reach them. + * Twin of WhoopStore.SleepMerge.richness. */ + private fun richness(s: SleepSession): Int { + if (!hasStages(s)) return 0 + val span = (s.endTs - s.startTs).toDouble() + return if (com.noop.analytics.HypnogramCoverage.isHoled(s.stagesJSON, span)) 1 else 2 + } + + /** A day's richness is that of its BEST session — a day with a fully-staged main night plus an + * unstaged nap is a staged day (#715 keeps every session of the winning day either way). + * Twin of WhoopStore.SleepMerge.dayRichness. */ + private fun dayRichness(sessions: List): Int = + sessions.fold(0) { acc, s -> maxOf(acc, richness(s)) } } } diff --git a/android/app/src/main/java/com/noop/data/WhoopSerialIdentity.kt b/android/app/src/main/java/com/noop/data/WhoopSerialIdentity.kt new file mode 100644 index 0000000000..9622ccd25b --- /dev/null +++ b/android/app/src/main/java/com/noop/data/WhoopSerialIdentity.kt @@ -0,0 +1,83 @@ +package com.noop.data + +/** + * The stable WHOOP device id derived from the strap's own serial (#1303). Byte-parity twin of Swift + * `WhoopSerialIdentity`. + * + * WHOOP strap identity is otherwise a TRANSIENT Bluetooth address/UUID: a re-pair or factory reset mints a + * fresh one, so the same physical strap forks into a second registry row and orphans its history (#1193). + * The ring path already solved this — `DeviceRegistry.adoptSerialIdentity` re-points a provisional id onto + * a serial id and migrates every device-scoped row — and this is the WHOOP half of the same idea. + * + * Pure and store-free so both platforms can pin the composition and, more importantly, the REFUSALS: a + * blank or implausible serial must yield null and leave the existing id alone. Adopting onto a junk id + * would be worse than not adopting at all, because the migration moves every row onto it. + */ +object WhoopSerialIdentity { + + /** + * The one place the WHOOP id namespace is spelled. The add-device flow mints `whoop-

` and the + * registry classifies on the same prefix; a serial id joins the same namespace so every existing prefix + * check keeps working unchanged. + */ + const val ID_PREFIX = "whoop" + + /** + * Shortest serial worth adopting. A 5.0/MG DIS serial is far longer; this only rejects a truncated or + * placeholder read, which a partial GATT response can produce. + */ + const val MIN_SERIAL_LENGTH = 6 + + private val ALLOWED = ('A'..'Z').toSet() + ('0'..'9').toSet() + '-' + + /** + * The `whoop-` id for a strap serial, or null when the serial cannot be trusted to identify it. + * + * Refuses blank/whitespace, anything under [MIN_SERIAL_LENGTH], and any serial carrying a character + * outside `[A-Z0-9-]` after upper-casing — a DIS read that returns a descriptive string rather than a + * serial should never become a device id. Upper-cased so the same strap read twice, in either case, + * resolves to ONE id rather than two. + */ + fun adoptedId(serial: String?): String? { + val raw = serial?.trim().orEmpty() + if (raw.isEmpty()) return null + val up = raw.uppercase() + if (up.length < MIN_SERIAL_LENGTH) return null + if (!up.all { it in ALLOWED }) return null + return "$ID_PREFIX-$up" + } + + /** + * Whether this pairing's id may be re-pointed onto a serial id at all. + * + * ONLY a provisional `whoop-
` id qualifies. The legacy `my-whoop` seed is deliberately + * EXCLUDED, and that exclusion is what makes this safe to ship before #1304: every existing + * single-WHOOP install is still on that seed, ~47 code paths still read the literal "my-whoop" + * directly, and [com.noop.ble.WhoopBleClient] documents that the single-WHOOP path never reassigns its + * deviceId. Adopting it would migrate the whole history onto `whoop-` while new samples kept + * being written under "my-whoop" - a split history that reads as data loss. + * + * The legacy seed joins this path as part of #1304, once the literals no longer assume it. + */ + fun mayAdopt(currentId: String): Boolean = currentId.startsWith("$ID_PREFIX-") + + /** + * True when [id] is already the serial id for [serial] — the steady state on every reconnect after the + * first adoption, and the cheap early-out that keeps re-adoption from doing database work per connect. + */ + fun isAlreadyAdopted(id: String, serial: String?): Boolean { + val target = adoptedId(serial) ?: return false + return id == target + } + + /** + * What may be written to a SHAREABLE strap log. The serial identifies the device, so only its leading + * characters are ever logged — the same rule `noteWhoop5VariantFromDis` already applies to the variant + * line. Never log [adoptedId]'s result directly. + */ + fun logSafe(serial: String?): String { + val raw = serial?.trim().orEmpty() + if (raw.isEmpty()) return "?" + return raw.uppercase().take(3) + "…" + } +} diff --git a/android/app/src/main/java/com/noop/ingest/HealthConnectImporter.kt b/android/app/src/main/java/com/noop/ingest/HealthConnectImporter.kt index 8d176708ea..e5028cb2ba 100644 --- a/android/app/src/main/java/com/noop/ingest/HealthConnectImporter.kt +++ b/android/app/src/main/java/com/noop/ingest/HealthConnectImporter.kt @@ -6,6 +6,7 @@ import androidx.health.connect.client.permission.HealthPermission import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord import androidx.health.connect.client.records.BodyFatRecord import androidx.health.connect.client.records.DistanceRecord +import androidx.health.connect.client.records.ExerciseRouteResult import androidx.health.connect.client.records.ExerciseSessionRecord import androidx.health.connect.client.records.HeartRateRecord import androidx.health.connect.client.records.HeartRateVariabilityRmssdRecord @@ -24,6 +25,7 @@ import androidx.health.connect.client.request.ReadRecordsRequest import androidx.health.connect.client.time.TimeRangeFilter import com.noop.analytics.FitnessAgeEngine import com.noop.analytics.HydrationStore +import com.noop.analytics.RouteMath import com.noop.data.AppleDaily import com.noop.data.DailyMetric import com.noop.data.ImportSummary @@ -42,7 +44,7 @@ import kotlin.reflect.KClass /** * Native Android Health Connect importer. * - * Reads a fixed set of record types out of the on-device Health Connect store via + * Reads a user-selected subset of supported record types out of the on-device Health Connect store via * `androidx.health.connect:connect-client`, aggregates them **per LOCAL calendar day** * (the device's default zone), and upserts them into the same Room store the WHOOP/Apple * importers write to (see [WhoopRepository]). All timestamps written are wall-clock UNIX @@ -59,9 +61,10 @@ import kotlin.reflect.KClass * user has NO raw "my-whoop" rows, so the computed source is what marks their days as owned. * - Exercise sessions -> [WorkoutRow] with source "health-connect". * - * Permissions are assumed to have been granted by the UI (via the Health Connect permission - * flow) BEFORE [import] is called. If Health Connect is unavailable, or the required - * read permissions are not in fact granted, [import] returns [ImportSummary.failure]. + * The UI requests only the selected categories before [import] is called. Partial grants are valid: + * the importer reads granted types inside those categories and skips everything else. If Health + * Connect is unavailable or none of the selected permissions is granted, it returns + * [ImportSummary.failure]. */ object HealthConnectImporter { @@ -92,25 +95,67 @@ object HealthConnectImporter { * clipping keeps a neighbouring activity inside this window from over-counting. */ private const val DISTANCE_MATCH_BUFFER_S = 300L - /** The record types this importer reads, in one place so PERMISSIONS stays in sync. */ - private val READ_RECORDS: List> = listOf( - StepsRecord::class, - TotalCaloriesBurnedRecord::class, - ActiveCaloriesBurnedRecord::class, - HeartRateRecord::class, - RestingHeartRateRecord::class, - HeartRateVariabilityRmssdRecord::class, - SleepSessionRecord::class, - OxygenSaturationRecord::class, - RespiratoryRateRecord::class, - Vo2MaxRecord::class, - WeightRecord::class, - BodyFatRecord::class, - LeanBodyMassRecord::class, - ExerciseSessionRecord::class, - DistanceRecord::class, - HydrationRecord::class, - ) + /** + * User-selectable Health Connect read scopes (#645). Each record type belongs to exactly one + * category so the permission prompt can explain what the user is granting and the importer can + * skip a deselected category even when Android still holds an older grant for it. + */ + enum class ImportCategory( + val storageKey: String, + internal val recordTypes: Set>, + ) { + RECOVERY( + "recovery", + setOf( + HeartRateRecord::class, + RestingHeartRateRecord::class, + HeartRateVariabilityRmssdRecord::class, + SleepSessionRecord::class, + OxygenSaturationRecord::class, + RespiratoryRateRecord::class, + HydrationRecord::class, + ), + ), + ACTIVITY( + "activity", + setOf( + StepsRecord::class, + TotalCaloriesBurnedRecord::class, + ActiveCaloriesBurnedRecord::class, + Vo2MaxRecord::class, + ExerciseSessionRecord::class, + DistanceRecord::class, + ), + ), + BODY_COMPOSITION( + "body-composition", + setOf( + WeightRecord::class, + BodyFatRecord::class, + LeanBodyMassRecord::class, + ), + ), + } + + internal val ALL_CATEGORIES: Set = ImportCategory.entries.toSet() + internal val DEFAULT_CATEGORIES: Set = setOf(ImportCategory.RECOVERY) + + /** Every supported read permission. Kept as the compatibility/all-capabilities view. */ + val PERMISSIONS: Set = permissionsFor(ALL_CATEGORIES) + + /** The permissions represented by exactly [categories], with no implicit broadening. */ + fun permissionsFor(categories: Set): Set = + recordTypesFor(categories).mapTo(linkedSetOf()) { HealthPermission.getReadPermission(it) } + + internal fun recordTypesFor(categories: Set): Set> = + categories.flatMapTo(linkedSetOf()) { it.recordTypes } + + internal fun readableRecordTypes( + categories: Set, + grantedPermissions: Set, + ): Set> = recordTypesFor(categories).filterTo(linkedSetOf()) { + HealthPermission.getReadPermission(it) in grantedPermissions + } /** * Hydration import window, in days (#949) — deliberately much shorter than [WINDOW_YEARS]. @@ -124,40 +169,120 @@ object HealthConnectImporter { private const val HYDRATION_WINDOW_DAYS = 30L /** - * The set of Health Connect read-permission strings the UI must request before calling - * [import]. One `READ_*` permission per record type in [READ_RECORDS]. + * The selected categories. New installs start with the narrow recovery/wellness group; an + * existing install that was already prompted under the old all-at-once flow keeps all categories + * enabled so an update never silently stops importing data it imported before (#645). */ - val PERMISSIONS: Set = - READ_RECORDS.map { HealthPermission.getReadPermission(it) }.toSet() + fun selectedCategories(context: Context): Set { + val preferences = prefs(context) + return categoriesFromStoredKeys( + preferences.getStringSet(CATEGORY_SELECTION_KEY, null)?.toSet(), + preferences.contains(PERMISSION_SIGNATURE_KEY), + ) + } + + fun setSelectedCategories(context: Context, categories: Set) { + require(categories.isNotEmpty()) { "At least one Health Connect category must be selected" } + prefs(context).edit() + .putStringSet(CATEGORY_SELECTION_KEY, categories.mapTo(linkedSetOf()) { it.storageKey }) + .apply() + } /** - * Whether the user has been asked about the CURRENT permission set (#949). + * The categories implied by grants Android already holds. * - * The import gate is `granted.any { ... }` by design (#150): partial grants are legitimate, so - * having any one permission is enough to import. But that also means a permission ADDED in an - * update is never requested — an existing user goes straight to importing and the new type reads - * as empty forever, indistinguishable from "you have no water logged". Water would have done - * nothing at all for every existing Android user. + * A user who granted Health Connect before #645 existed has no stored selection, and — if they + * onboarded before #949 added it — no permission signature either. The importer has shipped since + * 2026-06-07 and that key only since 2026-07-30, so there is a real cohort with neither. Falling back + * to [DEFAULT_CATEGORIES] for them would silently stop importing Activity and Body composition while + * Android still shows those permissions as granted: nothing on screen would say why steps stopped. * - * Comparing a stored fingerprint of [PERMISSIONS] catches that: when the set grows, the caller - * launches the request once so the user is asked about the new type, then marks it asked. It is - * asked ONCE — declining is remembered, so this never becomes a nag. + * Their grants are the honest record of what they agreed to, so read the scope back off those. */ - fun hasUnaskedPermissions(context: Context): Boolean = - prefs(context).getString(PERMISSION_SIGNATURE_KEY, null) != permissionSignature + internal fun categoriesFromGrantedPermissions(granted: Set): Set = + ImportCategory.entries.filterTo(linkedSetOf()) { category -> + permissionsFor(setOf(category)).any { it in granted } + } - /** Record that the user has now been asked about the current [PERMISSIONS] set. */ - fun markPermissionsAsked(context: Context) { - prefs(context).edit().putString(PERMISSION_SIGNATURE_KEY, permissionSignature).apply() + /** + * One-time backfill of the selection for a user who predates it, from what Android has granted. + * + * Only ever writes when NOTHING is stored, so a user who deliberately narrows to Recovery is never + * re-broadened, and it is safe to call from every entry point that can be the first one reached. + */ + fun migrateSelectionFromGrants(context: Context, granted: Set) { + if (prefs(context).getStringSet(CATEGORY_SELECTION_KEY, null) != null) return + val inferred = categoriesFromGrantedPermissions(granted) + if (inferred.isNotEmpty()) setSelectedCategories(context, inferred) } - private const val PERMISSION_SIGNATURE_KEY = "noop.hc.permissionSignature" + internal fun categoriesFromStoredKeys( + storedKeys: Set?, + hadLegacyPermissionSignature: Boolean, + ): Set { + if (storedKeys != null) { + val restored = ImportCategory.entries.filterTo(linkedSetOf()) { it.storageKey in storedKeys } + if (restored.isNotEmpty()) return restored + } + return if (hadLegacyPermissionSignature) ALL_CATEGORIES else DEFAULT_CATEGORIES + } + + /** + * Whether the user has been asked about every permission in the selected categories (#949/#645). + * + * The stored value is the union of permissions previously presented, not just the last selected + * set. Narrowing a selection therefore never causes another prompt; adding a category or adding a + * record type to an enabled category prompts once. Declining is still remembered. + * + * The old implementation stored all permissions as the same comma-separated signature. Parsing + * it as an asked set makes this migration backward-compatible without a preference rewrite. + */ + fun hasUnaskedPermissions( + context: Context, + categories: Set = selectedCategories(context), + ): Boolean = unaskedPermissions(askedPermissions(context), categories).isNotEmpty() + + /** Record that the user has now been asked about the selected categories' permissions. */ + fun markPermissionsAsked( + context: Context, + categories: Set = selectedCategories(context), + ) { + val asked = askedPermissions(context) + permissionsFor(categories) + prefs(context).edit().putString(PERMISSION_SIGNATURE_KEY, asked.sorted().joinToString(",")).apply() + } - private val permissionSignature: String get() = PERMISSIONS.sorted().joinToString(",") + internal fun unaskedPermissions( + asked: Set, + categories: Set, + ): Set = permissionsFor(categories) - asked + + private fun askedPermissions(context: Context): Set = + prefs(context).getString(PERMISSION_SIGNATURE_KEY, null) + ?.split(',') + ?.filterTo(linkedSetOf()) { it.isNotBlank() } + .orEmpty() + + private const val PERMISSION_SIGNATURE_KEY = "noop.hc.permissionSignature" + private const val CATEGORY_SELECTION_KEY = "noop.hc.importCategories" private fun prefs(context: Context) = context.getSharedPreferences(NoopPrefs.NAME, Context.MODE_PRIVATE) + /** #1735: stamp a COMPLETED import run so the diagnostics header can say when data last moved, not + * merely how many rows exist. Raw keys written inline, read back by AndroidDiagnostics - the same + * shape `sync.lastWriteOkAt` uses. Guarded: a stamp is never worth failing an import over. */ + private fun recordImportRun(context: Context, rows: Int, throughDay: String?) { + runCatching { + prefs(context).edit() + .putLong("hc.lastImportOkAt", System.currentTimeMillis() / 1000) + .putInt("hc.lastImportRows", rows) + .apply { + if (throughDay != null) putString("hc.lastImportThroughDay", throughDay) + } + .apply() + } + } + /** * Whether Health Connect is installed/available on this device. * One of [HealthConnectClient.SDK_AVAILABLE], @@ -177,7 +302,14 @@ object HealthConnectImporter { * [heightCm] is the user's profile height, used ONLY to derive BMI on days that carry a weight * (Health Connect has no BMI record, unlike Apple Health). Pass 0.0 to skip BMI derivation. */ - suspend fun import(context: Context, repo: WhoopRepository, heightCm: Double = 0.0): ImportSummary { + suspend fun import( + context: Context, + repo: WhoopRepository, + heightCm: Double = 0.0, + // Null means "whatever the user has selected", resolved AFTER the grant-based migration below. A + // non-lazy default is evaluated at CALL time, before the migration could widen it. + categories: Set? = null, + ): ImportSummary { if (sdkStatus(context) != HealthConnectClient.SDK_AVAILABLE) { return ImportSummary.failure(SOURCE, "Health Connect is not available on this device.") } @@ -200,15 +332,22 @@ object HealthConnectImporter { } catch (e: Exception) { return ImportSummary.failure(SOURCE, "Could not read Health Connect permissions: ${e.message}") } + // #645 follow-up: a user who predates the category selector has nothing stored — recover their + // real scope from the grants before deciding what to read, or the first import after the update + // would quietly narrow them to Recovery. + migrateSelectionFromGrants(context, granted) + val effectiveCategories = categories ?: selectedCategories(context) + // Partial permissions are fine (#150): import the record types the user DID grant and skip the // rest, instead of refusing the whole import when any single type is missing. Each per-type read // below is already independently fault-tolerant — a type whose read permission was revoked throws // and is caught/skipped in [readAll] (same path as #34) — so we only need to bail when NOTHING is // granted. The user choosing exactly what NOOP can see is the intended behaviour. - if (granted.none { it in PERMISSIONS }) { + val selectedPermissions = permissionsFor(effectiveCategories) + if (granted.none { it in selectedPermissions }) { return ImportSummary.failure( SOURCE, - "No Health Connect data types are granted. Allow at least one type for NOOP in Health Connect, then import.", + "No selected Health Connect data types are granted. Allow at least one selected type, then import.", ) } @@ -218,6 +357,19 @@ object HealthConnectImporter { val filter = TimeRangeFilter.between(start, end) // #528: skip our own writes on import (see readAll / isSelfWritten). val selfPackage = context.packageName + val selectedRecordTypes = readableRecordTypes(effectiveCategories, granted) + + // A granted permission can outlive the category selection that originally requested it. Gate + // on BOTH here so switching a category off stops its reads immediately without requiring the + // user to visit Android's Health Connect settings and revoke the old grant manually (#645). + suspend fun readSelected( + type: KClass, + range: TimeRangeFilter = filter, + onRecord: (T) -> Unit, + ): Boolean { + if (type !in selectedRecordTypes) return false + return readAll(client, type, range, selfPackage, onRecord) + } // Per-day accumulators. Keyed by "YYYY-MM-DD" (local). val acc = HashMap() @@ -259,14 +411,14 @@ object HealthConnectImporter { // walk, so summing across sources double-counts (~2x). Sum WITHIN a source (keyed by the record's // dataOrigin package), then take the MAX source per day at write-out, mirroring the de-overlap // already shipped on iOS/macOS and the Android XML importer. - readAll(client, StepsRecord::class, filter, selfPackage) { r -> + readSelected(StepsRecord::class) { r -> val b = bucket(dayOf(r.startTime, r.startZoneOffset)) val src = r.metadata.dataOrigin.packageName b.stepsBySource[src] = (b.stepsBySource[src] ?: 0L) + r.count } // --- Total calories burned (basal + active) --- // #589: per-SOURCE sums, max-across-sources at write-out (same overlap reasoning as steps). - readAll(client, TotalCaloriesBurnedRecord::class, filter, selfPackage) { r -> + readSelected(TotalCaloriesBurnedRecord::class) { r -> val b = bucket(dayOf(r.startTime, r.startZoneOffset)) val src = r.metadata.dataOrigin.packageName b.totalKcalBySource[src] = (b.totalKcalBySource[src] ?: 0.0) + r.energy.inKilocalories @@ -278,7 +430,7 @@ object HealthConnectImporter { // per-record window list below gets EVERY record, tagged with its source: the per-workout credit // de-overlaps by source ITSELF (#835 — it used to cross-source SUM, roughly doubling a ride that // two apps both logged), so the per-source map here only governs the day total. - readAll(client, ActiveCaloriesBurnedRecord::class, filter, selfPackage) { r -> + readSelected(ActiveCaloriesBurnedRecord::class) { r -> val b = bucket(dayOf(r.startTime, r.startZoneOffset)) val src = r.metadata.dataOrigin.packageName b.activeKcalBySource[src] = (b.activeKcalBySource[src] ?: 0.0) + r.energy.inKilocalories @@ -286,7 +438,7 @@ object HealthConnectImporter { KcalRecord(r.startTime.epochSecond, r.endTime.epochSecond, r.energy.inKilocalories, src)) } // --- Heart rate (instantaneous samples) -> per-day average --- - readAll(client, HeartRateRecord::class, filter, selfPackage) { r -> + readSelected(HeartRateRecord::class) { r -> for (s in r.samples) { val b = bucket(dayOf(s.time, r.startZoneOffset)) b.hrSum += s.beatsPerMinute @@ -294,19 +446,19 @@ object HealthConnectImporter { } } // --- Resting heart rate -> per-day average (rounded to Int) --- - readAll(client, RestingHeartRateRecord::class, filter, selfPackage) { r -> + readSelected(RestingHeartRateRecord::class) { r -> val b = bucket(dayOf(r.time, r.zoneOffset)) b.rhrSum += r.beatsPerMinute b.rhrCount += 1 } // --- HRV (RMSSD, ms) -> per-day average --- - readAll(client, HeartRateVariabilityRmssdRecord::class, filter, selfPackage) { r -> + readSelected(HeartRateVariabilityRmssdRecord::class) { r -> val b = bucket(dayOf(r.time, r.zoneOffset)) b.hrvSum += r.heartRateVariabilityMillis b.hrvCount += 1 } // --- Sleep sessions -> per-day total sleep minutes, assigned to the WAKE day --- - readAll(client, SleepSessionRecord::class, filter, selfPackage) { r -> + readSelected(SleepSessionRecord::class) { r -> // Wake-day keyed, so the END offset is the right one — but a writer that sets only the // start offset is common, and the start is far better evidence of the sleeper's zone than // the phone's zone at import time. Fall through start before giving up. @@ -330,19 +482,19 @@ object HealthConnectImporter { )) } // --- SpO2 (%) -> per-day average --- - readAll(client, OxygenSaturationRecord::class, filter, selfPackage) { r -> + readSelected(OxygenSaturationRecord::class) { r -> val b = bucket(dayOf(r.time, r.zoneOffset)) b.spo2Sum += r.percentage.value b.spo2Count += 1 } // --- Respiratory rate (breaths/min) -> per-day average --- - readAll(client, RespiratoryRateRecord::class, filter, selfPackage) { r -> + readSelected(RespiratoryRateRecord::class) { r -> val b = bucket(dayOf(r.time, r.zoneOffset)) b.respSum += r.rate b.respCount += 1 } // --- VO2 max (ml/kg/min) -> latest value of the day wins --- - readAll(client, Vo2MaxRecord::class, filter, selfPackage) { r -> + readSelected(Vo2MaxRecord::class) { r -> val b = bucket(dayOf(r.time, r.zoneOffset)) if (r.time.epochSecond >= b.vo2maxTs) { b.vo2max = r.vo2MillilitersPerMinuteKilogram @@ -350,7 +502,7 @@ object HealthConnectImporter { } } // --- Weight (kg) -> latest value of the day wins --- - readAll(client, WeightRecord::class, filter, selfPackage) { r -> + readSelected(WeightRecord::class) { r -> val b = bucket(dayOf(r.time, r.zoneOffset)) if (r.time.epochSecond >= b.weightTs) { b.weightKg = r.weight.inKilograms @@ -360,7 +512,7 @@ object HealthConnectImporter { // --- Body fat (%) -> latest value of the day wins. Health Connect's Percentage.value is // already 0-100 (unlike Apple's 0..1 fraction), so it stores as-is and matches the iOS // "body_fat" key. --- - readAll(client, BodyFatRecord::class, filter, selfPackage) { r -> + readSelected(BodyFatRecord::class) { r -> val b = bucket(dayOf(r.time, r.zoneOffset)) if (r.time.epochSecond >= b.bodyFatTs) { b.bodyFatPct = r.percentage.value @@ -368,7 +520,7 @@ object HealthConnectImporter { } } // --- Lean body mass (kg) -> latest value of the day wins (iOS "lean_mass" twin). --- - readAll(client, LeanBodyMassRecord::class, filter, selfPackage) { r -> + readSelected(LeanBodyMassRecord::class) { r -> val b = bucket(dayOf(r.time, r.zoneOffset)) if (r.time.epochSecond >= b.leanMassTs) { b.leanMassKg = r.mass.inKilograms @@ -376,9 +528,25 @@ object HealthConnectImporter { } } // --- Exercise sessions -> WorkoutRow(source="health-connect") --- - readAll(client, ExerciseSessionRecord::class, filter, selfPackage) { r -> + readSelected(ExerciseSessionRecord::class) { r -> val startS = r.startTime.epochSecond val endS = r.endTime.epochSecond + // #1205: read the GPS route when the provider attached one. Health Connect gates + // route data behind a SEPARATE permission (READ_EXERCISE_ROUTES) that cannot be + // requested via the standard dialog — it is granted in Settings or via + // ExerciseRouteRequestContract. exerciseRouteResult reports which state this session + // is in: Data (we have it), ConsentRequired (the user has not granted), or NoData. + // A graceful skip on the latter two keeps the import working without the route, so + // the workout still lands with its distance/HR — just no map, same as today. + val routePolyline: String? = when (val result = r.exerciseRouteResult) { + is ExerciseRouteResult.Data -> { + val pts = result.exerciseRoute.route.map { loc -> + RouteMath.LatLng(loc.latitude, loc.longitude) + } + if (pts.size >= 2) RouteMath.encode(pts) else null + } + else -> null + } workouts.add( WorkoutRow( deviceId = HC_DEVICE, @@ -398,6 +566,7 @@ object HealthConnectImporter { distanceM = null, zonesJSON = null, notes = r.title, + routePolyline = routePolyline, ) ) // Count exercises per local day on the start day for the WHOOP daily backfill. @@ -445,12 +614,11 @@ object HealthConnectImporter { var sum = 0L var n = 0L var max = 0L - readAll( - client, HeartRateRecord::class, + readSelected( + HeartRateRecord::class, TimeRangeFilter.between( Instant.ofEpochSecond(w.startTs), Instant.ofEpochSecond(w.endTs) ), - selfPackage, ) { hr -> for (s in hr.samples) { sum += s.beatsPerMinute @@ -484,13 +652,12 @@ object HealthConnectImporter { // so a neighbouring activity's record inside the buffer can't over-count. val ws = w.startTs val we = w.endTs - readAll( - client, DistanceRecord::class, + readSelected( + DistanceRecord::class, TimeRangeFilter.between( Instant.ofEpochSecond(ws - DISTANCE_MATCH_BUFFER_S), Instant.ofEpochSecond(we + DISTANCE_MATCH_BUFFER_S), ), - selfPackage, ) { d -> val rs = d.startTime.epochSecond val re = d.endTime.epochSecond @@ -516,8 +683,8 @@ object HealthConnectImporter { // twice. Taking the max here would silently drop whichever app logged less. val hydrationStart = LocalDate.now(zone).minusDays(HYDRATION_WINDOW_DAYS - 1) .atStartOfDay(zone).toInstant() - hydrationReadOk = readAll( - client, HydrationRecord::class, TimeRangeFilter.between(hydrationStart, end), selfPackage, + hydrationReadOk = readSelected( + HydrationRecord::class, TimeRangeFilter.between(hydrationStart, end), ) { r -> // #1002: hydration deliberately keeps the PHONE's zone, unlike every other record here. // Its write is a windowed REPLACE: `windowDays` below is built from LocalDate.now(zone), @@ -559,6 +726,13 @@ object HealthConnectImporter { } if (acc.isEmpty() && workouts.isEmpty()) { + // #1735: record that an import RAN and what it brought. "Hours after a ride it still is not + // there" cannot be told from "Health Connect never imported it" without this: the log had per-source + // row counts but nothing about WHEN they last moved. Mirrors the sync.lastWriteOkAt pattern the + // strap-write path already uses. Best-effort - a stamp failure must not sink a good import. + // Stamped on the EMPTY path too, deliberately: "ran and found nothing" and "never ran" are + // different diagnoses and this is the only line that separates them. + recordImportRun(context, rows = 0, throughDay = null) return ImportSummary( source = SOURCE, counts = emptyMap(), @@ -704,6 +878,11 @@ object HealthConnectImporter { val lastDay = touchedDays.lastOrNull() val total = counts.values.sum() + // #1735: record that an import RAN and what it brought. "Hours after a ride it still is not + // there" cannot be told from "Health Connect never imported it" without this: the log had per-source + // row counts but nothing about WHEN they last moved. Mirrors the sync.lastWriteOkAt pattern the + // strap-write path already uses. Best-effort - a stamp failure must not sink a good import. + recordImportRun(context, rows = total, throughDay = lastDay) return ImportSummary( source = SOURCE, counts = counts, @@ -728,6 +907,7 @@ object HealthConnectImporter { */ suspend fun refreshTodaySteps(context: Context, repo: WhoopRepository): Int? { if (sdkStatus(context) != HealthConnectClient.SDK_AVAILABLE) return null + if (ImportCategory.ACTIVITY !in selectedCategories(context)) return null val client = client(context) val granted = try { client.permissionController.getGrantedPermissions() diff --git a/android/app/src/main/java/com/noop/ingest/HealthConnectLedger.kt b/android/app/src/main/java/com/noop/ingest/HealthConnectLedger.kt new file mode 100644 index 0000000000..7268478a8d --- /dev/null +++ b/android/app/src/main/java/com/noop/ingest/HealthConnectLedger.kt @@ -0,0 +1,105 @@ +package com.noop.ingest + +import android.content.Context + +/** + * What NOOP has already written to Health Connect, so it can take it back (#1677). + * + * The writer is idempotent by `clientRecordId` and versions each record so a later, fuller copy upserts + * over an earlier one. That covers a record whose id STAYS THE SAME. It cannot cover a record whose id + * stops being produced — Health Connect never removes an id you simply stop writing. + * + * That case is routine for sleep, not exotic. `sleepSession`'s primary key is (deviceId, startTs), so a + * re-detection that lands on a different start is a DIFFERENT ROW, and the #1248/#1284 heal deletes the + * stale row once a fuller copy wins — `SleepSessionDedup` exists because one night really can accumulate + * several rows with different starts. The exporter's only cleanup path, `absorbedClientIds`, is computed + * from rows STILL IN THE DATABASE, so the moment the heal drops that row the exporter loses all knowledge + * that it ever wrote a Health Connect record under its id. The record then cannot be corrected or removed + * by any later export, which is exactly what #1677 reports: re-running the export does not fix it. + * + * So the exporter needs a memory of its own. This is that memory: the ids written per record type, and + * the rule for which of them a later export should retract. + */ +object HealthConnectLedger { + + /** One entry per record type whose id embeds a MUTABLE timestamp. Day-keyed ids + * (`noop--`) need no ledger: a day never changes its own key. */ + const val SLEEP_PREFIX = "noop-sleep-" + const val WORKOUT_PREFIX = "noop-workout-" + + /** + * PER STRAP, and that is load-bearing rather than tidy. + * + * The export is device-scoped: `sleepSessionsMerged(deviceId)` reads that strap union the canonical + * id, so nights banked under a DIFFERENT strap are not in its result. With one shared ledger, an + * export running as strap B would find strap A's ids absent-but-in-window and retract them — + * deleting Health Connect records that are perfectly valid, for a user who did nothing but switch + * which strap is active. Before any of this those records merely lingered; a global key would have + * turned a harmless staleness into data loss. + * + * Keyed per strap, each export can only ever take back what that same strap put there. Nights under + * the canonical id appear in every strap's union, so they land in every ledger and are never the odd + * one out. + * + * Pure so the separation is pinned by a test rather than by this comment. + */ + fun ledgerKey(deviceId: String, prefix: String) = "hc.written.$deviceId.$prefix" + + fun previouslyWritten(context: Context, deviceId: String, prefix: String): Set = + prefs(context).getStringSet(ledgerKey(deviceId, prefix), emptySet()) ?: emptySet() + + fun remember(context: Context, deviceId: String, prefix: String, ids: Set) { + // A COPY: SharedPreferences keeps the very set instance it was handed, and mutating or re-reading + // it afterwards is documented as undefined. Cheap insurance against a bug that only shows up later. + prefs(context).edit().putStringSet(ledgerKey(deviceId, prefix), HashSet(ids)).apply() + } + + private fun prefs(context: Context) = + context.getSharedPreferences(com.noop.ui.NoopPrefs.NAME, Context.MODE_PRIVATE) + + /** + * What the ledger should carry into the NEXT export. + * + * Not simply the ids just written. A retraction that FAILED — Health Connect unavailable, the + * permission pulled mid-export, a transient remote error — still has a live record behind it, and + * dropping its id here would forget it forever. That is the exact failure this whole file exists to + * prevent, reintroduced through the error path, and the first version of this change had it. + * + * So a failed retraction stays on the books and is retried next time. It costs one id in a + * SharedPreferences set until it succeeds. + */ + fun ledgerAfterExport(current: Set, unretracted: Set): Set = + current + unretracted + + /** + * Which previously-written ids should this export RETRACT? + * + * The obvious answer — everything we wrote before and are not writing now — is WRONG, and wrong in + * the most expensive direction: it deletes the user's data. The export covers a rolling window, so a + * night that simply aged out of that window is absent from [current] while its Health Connect record + * is perfectly good. Retracting it would silently destroy history the user still has. + * + * So an id is retracted only when its own timestamp falls INSIDE the window this export actually + * covered. Outside it, the export had nothing to say about that record and says nothing. + * + * An id whose timestamp cannot be parsed is never retracted. It is not ours to reason about, and + * guessing costs data while abstaining costs one stale row. + * + * Pure so both rules are pinned without a Health Connect client or a clock. + */ + fun staleClientIds( + previous: Set, + current: Set, + prefix: String, + windowStartSec: Long, + nowSec: Long, + ): List = previous + .asSequence() + .filter { it !in current } + .filter { id -> + val ts = id.removePrefix(prefix).toLongOrNull() + ts != null && ts in windowStartSec..nowSec + } + .sorted() // deterministic, so a log line and a test read the same order + .toList() +} diff --git a/android/app/src/main/java/com/noop/ingest/HealthConnectWriter.kt b/android/app/src/main/java/com/noop/ingest/HealthConnectWriter.kt index 1dd8196aad..9cf0cd7c72 100644 --- a/android/app/src/main/java/com/noop/ingest/HealthConnectWriter.kt +++ b/android/app/src/main/java/com/noop/ingest/HealthConnectWriter.kt @@ -1,5 +1,7 @@ package com.noop.ingest +import kotlinx.coroutines.sync.withLock + import android.content.Context import androidx.health.connect.client.HealthConnectClient import androidx.health.connect.client.permission.HealthPermission @@ -89,6 +91,16 @@ object HealthConnectWriter { val PERMISSIONS: Set = WRITE_RECORDS.map { HealthPermission.getWritePermission(it) }.toSet() + /** + * #1677: exports are serialized. Three callers can start one - the post-sync hook in WhoopBleClient + * and two in AppViewModel - with no uniqueness guard between them, so two could previously overlap on + * the same external store. That is newly consequential now the writer keeps a ledger: both would read + * the same `previous`, both would write their own result, and the loser's carried-forward retractions + * would be forgotten, orphaning exactly the records this change exists to retract. Serializing also + * stops two exports doing the same insert work twice. + */ + private val exportMutex = kotlinx.coroutines.sync.Mutex() + /** * Write the last [WINDOW_DAYS] of computed metrics. Returns a [WritebackResult] — the record * count PLUS any per-concern failure categories, so a revoked permission no longer looks like a @@ -99,7 +111,10 @@ object HealthConnectWriter { * rows under `whoop-
`, so a hardcoded legacy "my-whoop" id reads empty tables and * exports nothing. */ - suspend fun write(context: Context, repo: WhoopRepository, deviceId: String): WritebackResult { + suspend fun write(context: Context, repo: WhoopRepository, deviceId: String): WritebackResult = + exportMutex.withLock { writeLocked(context, repo, deviceId) } + + private suspend fun writeLocked(context: Context, repo: WhoopRepository, deviceId: String): WritebackResult { if (HealthConnectClient.getSdkStatus(context) != HealthConnectClient.SDK_AVAILABLE) return WritebackResult.UNAVAILABLE // Guard the pre-insert work (client acquisition + the day read) the same way the concern inserts @@ -172,7 +187,7 @@ object HealthConnectWriter { } runCatching { writeHeartRate(client, context, repo, deviceId, version) } .fold({ total += it }, { failures += it.writebackCategory() }) - runCatching { writeSleep(client, repo, deviceId) } + runCatching { writeSleep(client, context, repo, deviceId) } .fold({ total += it }, { failures += it.writebackCategory() }) // #1525: separate attempt on purpose. The daily records above go in ONE insertRecords call, so a // missing permission there loses resting HR, HRV, SpO2 and respiratory rate together. On its own, @@ -213,14 +228,14 @@ object HealthConnectWriter { val floor = now - WINDOW_DAYS * 86_400 val frontier = maxOf(NoopPrefs.hcHrFrontier(context), floor) - val samples = repo.hrSamples(deviceId, from = frontier + 1, to = now, limit = 200_000) + val samples = repo.hrSamplesForDevice(deviceId, from = frontier + 1, to = now, limit = 200_000) .map { HealthExportPlan.HrPoint(it.ts, it.bpm) } if (samples.isEmpty()) return 0 // Workout + sleep windows where the full-resolution series matters; everything else decimates. val windows = buildList { repo.workouts(deviceId, frontier, now).forEach { add(HealthExportPlan.Window(it.startTs, it.endTs)) } - repo.sleepSessions(deviceId, frontier, now).forEach { add(HealthExportPlan.Window(it.startTs, it.endTs)) } + repo.sleepSessionsForDevice(deviceId, frontier, now).forEach { add(HealthExportPlan.Window(it.startTs, it.endTs)) } } val plan = HealthExportPlan.heartRate(samples, windows, frontier) @@ -259,13 +274,21 @@ object HealthConnectWriter { * and the absorbed fragments' old per-fragment records are DELETED (HC upserts by id but never * removes an id we stop writing). */ - private suspend fun writeSleep(client: HealthConnectClient, repo: WhoopRepository, deviceId: String): Int { + private suspend fun writeSleep( + client: HealthConnectClient, + context: Context, + repo: WhoopRepository, + deviceId: String, + ): Int { val now = System.currentTimeMillis() / 1000 val floor = now - WINDOW_DAYS * 86_400 val sessions = repo.sleepSessionsMerged(deviceId, from = floor, to = now) .map { HealthExportPlan.SleepInput(it.startTs, it.effectiveStartTs, it.endTs, it.stagesJSON) } val offsetSec = (java.util.TimeZone.getDefault().getOffset(now * 1000) / 1000).toLong() val plans = HealthExportPlan.sleepSessions(sessions, now, offsetSec) + // Deliberately BEFORE the ledger below. "No plans" is not the same statement as "the user has no + // sleep": a read that came back empty for any reason would, with retraction enabled, delete every + // night this window covers. Saying nothing is the recoverable failure; deleting is not. if (plans.isEmpty()) return 0 val zone = ZoneId.systemDefault() @@ -295,13 +318,51 @@ object HealthConnectWriter { // Clear absorbed fragments' old records BEFORE the merged upsert, so a night previously // exported as two entries never lingers as one merged + one stale fragment. (#364) val absorbed = plans.flatMap { it.absorbedClientIds } - if (absorbed.isNotEmpty()) { + // #1677: and the ids we wrote on a PREVIOUS export that this one no longer produces. `absorbed` + // cannot cover those: it is derived from sleepSession rows still in the database, and the + // #1248/#1284 heal deletes a stale row the moment a fuller copy of that night wins. A record + // exported mid-sync under the partial night's start then had nothing left pointing at it, so no + // later export could correct or remove it — the permanent wrong duration #1677 describes. + // Scoped to this export's own window by `staleClientIds`, so a night that merely aged out is + // never retracted. + val currentIds = plans.map { it.clientId }.toSet() + val stale = HealthConnectLedger.staleClientIds( + previous = HealthConnectLedger.previouslyWritten( + context, deviceId, HealthConnectLedger.SLEEP_PREFIX), + current = currentIds, + prefix = HealthConnectLedger.SLEEP_PREFIX, + windowStartSec = floor, + nowSec = now, + ) + val retract = (absorbed + stale).distinct() + // Ids whose retraction did NOT land. They stay on the books so the next export tries again - + // forgetting one would orphan its record permanently, which is the very failure this fixes. + val unretracted = LinkedHashSet() + if (retract.isNotEmpty()) { + // One call for the common case. If it throws, retry per id: the reporter of #1677 had already + // deleted the bad record by hand, and an id that is no longer there must not take the rest of + // the batch down with it. `retract` is a handful of entries at most, so the fallback is cheap. runCatching { client.deleteRecords(SleepSessionRecord::class, - recordIdsList = emptyList(), clientRecordIdsList = absorbed) + recordIdsList = emptyList(), clientRecordIdsList = retract) + }.onFailure { + for (one in retract) { + runCatching { + client.deleteRecords(SleepSessionRecord::class, + recordIdsList = emptyList(), clientRecordIdsList = listOf(one)) + }.onFailure { unretracted += one } + } } } - return insertChunked(client, records) + val written = insertChunked(client, records) + // Recorded only AFTER the write lands. Remembering ids we failed to write would make the NEXT + // export retract records that never existed - harmless in itself, but it would also drop the ids + // that do need retracting from the ledger, quietly restoring the bug this fixes. + HealthConnectLedger.remember( + context, deviceId, HealthConnectLedger.SLEEP_PREFIX, + HealthConnectLedger.ledgerAfterExport(currentIds, unretracted), + ) + return written } /** diff --git a/android/app/src/main/java/com/noop/ingest/RawSensorExport.kt b/android/app/src/main/java/com/noop/ingest/RawSensorExport.kt index 3e9028bc89..9ad766e874 100644 --- a/android/app/src/main/java/com/noop/ingest/RawSensorExport.kt +++ b/android/app/src/main/java/com/noop/ingest/RawSensorExport.kt @@ -66,6 +66,8 @@ object RawSensorExport { to: Long, limit: Int = 200_000, ): Map { + out.write(HEADER); out.write("\n") + if (from > to) return emptyMap() // index: 0 hr_bpm,1 rr_ms,2 grav_x,3 grav_y,4 grav_z,5 step_counter,6 ppg_bpm,7 ppg_conf, // 8 spo2_red,9 spo2_ir,10 skintemp_raw,11 resp_raw,12 band_sleep_state,13 event_kind,14 event_payload val rows = ArrayList() @@ -75,11 +77,11 @@ object RawSensorExport { counts["hr"] = hr.size for (s in hr) rows += LineRow(s.ts, line("hr", s.ts, 0 to n(s.bpm))) - val rr = repo.rrIntervals(deviceId, from, to, limit) + val rr = repo.rrIntervalsForDevice(deviceId, from, to, limit) counts["rr"] = rr.size for (s in rr) rows += LineRow(s.ts, line("rr", s.ts, 1 to n(s.rrMs))) - val grav = repo.gravitySamples(deviceId, from, to, limit) + val grav = repo.gravitySamplesForDevice(deviceId, from, to, limit) counts["gravity"] = grav.size for (s in grav) rows += LineRow(s.ts, line("gravity", s.ts, 2 to n(s.x), 3 to n(s.y), 4 to n(s.z))) @@ -118,7 +120,6 @@ object RawSensorExport { // Stable sort by ts asc (a stream's intra-ts order is its query's secondary key). rows.sortBy { it.ts } - out.write(HEADER); out.write("\n") for (r in rows) { out.write(r.line); out.write("\n") } return counts } diff --git a/android/app/src/main/java/com/noop/ingest/RouteExport.kt b/android/app/src/main/java/com/noop/ingest/RouteExport.kt index 75c9c5625a..cb131a25e6 100644 --- a/android/app/src/main/java/com/noop/ingest/RouteExport.kt +++ b/android/app/src/main/java/com/noop/ingest/RouteExport.kt @@ -47,17 +47,18 @@ object RouteExport { avgHr: Int? = null, maxHr: Int? = null, ): ByteArray = when (format) { - Format.GPX -> buildGpx(points, startTs, endTs, sport, distanceM).toByteArray(Charsets.UTF_8) + Format.GPX -> buildGpx(points, startTs, endTs, sport).toByteArray(Charsets.UTF_8) Format.FIT -> buildFit(points, startTs, endTs, sport, distanceM, energyKcal, avgHr, maxHr) } - /** GPX 1.1 track. Each `trkpt` carries lat/lon + an interpolated `time`; no `ele`/HR (none stored). */ + /** GPX 1.1 track. Each `trkpt` carries lat/lon + an interpolated `time`; no `ele`/HR (none stored). + * Takes no distance: GPX 1.1 has no track-level distance element, so a reader derives it from the + * trackpoints. FIT does carry one, which is why [buildFit] still takes it. */ fun buildGpx( points: List, startTs: Long, endTs: Long, sport: String?, - distanceM: Double? = null, ): String { val canon = canonicalSport(sport) val times = interpolatedTimes(points.size, startTs, endTs) diff --git a/android/app/src/main/java/com/noop/ingest/WhoopCsvImporter.kt b/android/app/src/main/java/com/noop/ingest/WhoopCsvImporter.kt index 4204a6f73e..a1011a0d01 100644 --- a/android/app/src/main/java/com/noop/ingest/WhoopCsvImporter.kt +++ b/android/app/src/main/java/com/noop/ingest/WhoopCsvImporter.kt @@ -346,7 +346,13 @@ object WhoopCsvImporter { val recovery = row.double("recovery_score_pct") val restingHr = row.double("resting_heart_rate_bpm", "resting_heart_rate") val avgHrv = row.double("heart_rate_variability_ms", "heart_rate_variability_rmssd_ms") - val skinTemp = row.double("skin_temp_celsius", "skin_temp_f") + // #1849: a Fahrenheit WHOOP export ships `skin_temp_f`, NOT `skin_temp_celsius`. Treating + // the two as aliases stored the °F value unconverted (92.3 °F → 92.3 in a °C column), + // which read as a lethal fever and poisoned the baseline. Read each as its OWN key and + // convert the Fahrenheit value on the way in. `skin_temp_celsius` wins when both are + // present (a Celsius export is the canonical form). Byte-identical with the Swift twin. + val skinTemp = row.double("skin_temp_celsius") + ?: row.double("skin_temp_f")?.let { (it - 32.0) * 5.0 / 9.0 } val spo2 = row.double("blood_oxygen_pct", "blood_oxygen_pct_pct") val strain = row.double("day_strain") val resp = row.double("respiratory_rate_rpm", "respiratory_rate") diff --git a/android/app/src/main/java/com/noop/oura/Commands.kt b/android/app/src/main/java/com/noop/oura/Commands.kt index 9b6f3b2a05..933cdd5186 100644 --- a/android/app/src/main/java/com/noop/oura/Commands.kt +++ b/android/app/src/main/java/com/noop/oura/Commands.kt @@ -27,8 +27,12 @@ object OuraCommands { // The SpO2 feature id. Per OURA_PROTOCOL.md s7.1. const val featureSpO2 = 0x04 - // The real-steps feature id. Server-flag-gated (activity/real_steps, default off), so it is never - // emitted for an offline NOOP-only ring. Per OURA_PROTOCOL.md s7.1 / s7.3 [open_oura-feat]. + // The real-steps feature id (`activity/real_steps`). Nominally server-flag-gated per + // OURA_PROTOCOL.md s7.1/s7.3 [open_oura-feat], but on-device 2026-08-25 real-steps status reads + // came back enabled (status=1) from BOTH an authenticated Oura-app session and NOOP's own fully + // offline, unauthenticated read of the same ring - the gate is ring-side state, not tied to which + // client asks or whether that client is cloud-authenticated. Do not assume "off for NOOP" without + // checking a live read. const val featureRealSteps = 0x0b // MARK: - Pre-auth / identity (unauthenticated OK) @@ -168,9 +172,11 @@ object OuraCommands { OuraCommand("spo2_status", intArrayOf(0x2F, 0x02, 0x20, featureSpO2)) /** - * Read the real-steps feature status, `2f 02 20 0b` (READ verb, not enable). The `0x21` reply confirms - * the server-flag gate (`activity/real_steps`, default off) from the ring itself. Read-only diagnostic. - * [open_oura-feat] + * Read the real-steps feature status, `2f 02 20 0b` (READ verb, not enable). The `0x21` reply reports + * the ring's own real_steps gate state - NOT reliably "off" for an offline ring: on-device + * 2026-08-25 this read back status=1 (enabled) from NOOP's own unauthenticated connection, matching + * the real Oura app's read of the same ring byte-for-byte. Read-only diagnostic either way - never + * enables anything, never writes a mode. [open_oura-feat] */ fun realStepsReadStatus(): OuraCommand = OuraCommand("realsteps_status", intArrayOf(0x2F, 0x02, 0x20, featureRealSteps)) diff --git a/android/app/src/main/java/com/noop/oura/OuraDriver.kt b/android/app/src/main/java/com/noop/oura/OuraDriver.kt index 665570b856..8faa443682 100644 --- a/android/app/src/main/java/com/noop/oura/OuraDriver.kt +++ b/android/app/src/main/java/com/noop/oura/OuraDriver.kt @@ -648,19 +648,39 @@ class OuraDriver( */ private const val SAMPLE_FUTURE_TOLERANCE_SECONDS = 300L + /** + * How far the ring's clock may run AHEAD of [lowerBoundTicks] and still be recognised. The bound + * is a stale resume cursor or the oldest ring-time a drain has seen, either of which can trail + * the ring's clock by the ring's whole banked depth (~14 days) plus however long the cursor has + * been stuck — the original 7-day window silently excluded exactly that case: in the 2026-09-02/03 + * iOS captures an 8.2-day-stale cursor could never be re-anchored, so it could never advance, so + * the staleness only grew — one full re-serve of the same window per launch, forever. Widening + * costs no honesty: both readings fit the window only when + * `window >= 9 × lowerBound`, i.e. a ring under ~5 days of clock — and that case still resolves + * to null below, exactly as before. Byte-identical twin of Swift's syncTimeAnchorWindowTicks. + */ + const val SYNC_TIME_ANCHOR_WINDOW_TICKS = 38_880_000L // 45 days of 100 ms ticks + /** * Resolve the 0x13 SyncTime-response device timestamp into ring TICKS, or null when no * unambiguous reading exists. ringverse BLE.md labels the field "seconds" but the ring's record * clock runs in 100 ms ticks, so both readings are tried: the raw value (already ticks) and - * value×10 (seconds→ticks). The ring's clock at connect must sit shortly AFTER where the last - * drain ended, so a candidate is plausible iff it falls in `[historyCursor, historyCursor + - * 7 days]`; exactly one must fit (ambiguity or a fresh/reset cursor → null → the caller logs - * raw instead of guessing). Pure. Byte-identical twin of Swift's syncTimeAnchorCandidate. + * value×10 (seconds→ticks). The ring's clock at connect must sit AFTER any ring-time we already + * know about, so a candidate is plausible iff it falls in `[lowerBoundTicks, lowerBoundTicks + + * SYNC_TIME_ANCHOR_WINDOW_TICKS]`; exactly one must fit (ambiguity or no reference at all → null + * → the caller logs raw instead of guessing). + * + * [lowerBoundTicks] is any ring-time known to precede the ring's clock NOW: the persisted resume + * cursor at connect, or — when that is 0 (fresh pair / post-reboot reset) or too stale — the + * largest envelope ring-time the drain has actually seen ([OuraHistoryDrain.maxSeenRingTime]), + * which needs no anchor to read and so breaks the cursor↔anchor deadlock (2026-09-02/03 captures). + * + * Pure. Byte-identical twin of Swift's syncTimeAnchorCandidate. */ - fun syncTimeAnchorCandidate(responseValue: Long, historyCursor: Long): Long? { - if (historyCursor <= 0) return null - val lower = historyCursor - val upper = lower + 6_048_000L // 7 days of 100 ms ticks + fun syncTimeAnchorCandidate(responseValue: Long, lowerBoundTicks: Long): Long? { + if (lowerBoundTicks <= 0) return null + val lower = lowerBoundTicks + val upper = lower + SYNC_TIME_ANCHOR_WINDOW_TICKS val readings = listOf(responseValue, responseValue * 10) val fits = readings.filter { it in lower..upper && it <= 0xFFFF_FFFFL } if (fits.size != 1) return null diff --git a/android/app/src/main/java/com/noop/protocol/BackfillCaptureJsonl.kt b/android/app/src/main/java/com/noop/protocol/BackfillCaptureJsonl.kt index fe43448fa3..d65918ea9f 100644 --- a/android/app/src/main/java/com/noop/protocol/BackfillCaptureJsonl.kt +++ b/android/app/src/main/java/com/noop/protocol/BackfillCaptureJsonl.kt @@ -1,15 +1,48 @@ package com.noop.protocol +/** + * The export spelling of a [FrameRejectReason], identical to the Swift enum's `rawValue`. + * + * The capture files and the strap-log readouts of the two platforms are read side by side by whoever + * is mapping an unknown frame, so the reason has to READ the same on both — the Kotlin identifier + * follows Kotlin's enum convention, the wire form follows Swift's. Defined here, next to the export + * record, because this is where the export form lives; the BLE-side tally reads it from here rather + * than keeping a second copy that could drift. + */ +val FrameRejectReason.wireName: String + get() = when (this) { + FrameRejectReason.NONE -> "none" + FrameRejectReason.NO_START_OF_FRAME -> "noStartOfFrame" + FrameRejectReason.BELOW_MINIMUM_LENGTH -> "belowMinimumLength" + FrameRejectReason.LENGTH_MISMATCH -> "lengthMismatch" + FrameRejectReason.HEADER_CHECKSUM_MISMATCH -> "headerChecksumMismatch" + FrameRejectReason.PAYLOAD_CRC_MISMATCH -> "payloadCRCMismatch" + FrameRejectReason.PAYLOAD_CRC_UNVERIFIABLE -> "payloadCRCUnverifiable" + } + data class BackfillCaptureRecord( val capturedAtMs: Long, val sessionId: String, val characteristic: String, + /** + * The decoded packet type — kept even for a frame the verifier REJECTED. That is the point of a + * capture: an unmapped or corrupted frame is precisely what a mapper wants to see, and blanking its + * type would throw away the one thing the decoder did learn. The cause lives in [rejectReason]. + */ val typeName: String, val crcOk: Boolean?, val offload: Boolean, val size: Int, val parsed: Map, val hex: String, + /** + * Why the frame failed its integrity check; [FrameRejectReason.NONE] on an intact one. ADDITIVE in + * the strict sense: it is encoded as a new trailing key ONLY when there is a reason, so the line an + * intact frame produces is unchanged, and the default keeps existing constructions compiling. Twin + * of the Swift `PuffinCaptureRecord.rejectReason` / `reject_reason`, whose decoder reads an absent + * key as `none` for exactly the same reason. + */ + val rejectReason: FrameRejectReason = FrameRejectReason.NONE, ) object BackfillCaptureJsonl { @@ -35,6 +68,18 @@ object BackfillCaptureJsonl { appendJsonValue(record.parsed) append(',') appendField("hex", record.hex) + // Only on a REJECTION, and only the reason the verifier actually reported. Two reasons for + // the omission rather than an unconditional `"reject_reason":"none"`: + // - it keeps the line an intact frame produces byte-identical to the previous format, so + // every existing reader and every pinned golden line keeps working unchanged; and + // - "key absent ⇒ none" is exactly the Swift decoder's contract for this field + // (`decodeIfPresent(...) ?? .none`), so a capture written here still reads there. + // A key printed on every line would also train the eye to skip the field, which is the + // opposite of why it exists. + if (record.rejectReason != FrameRejectReason.NONE) { + append(',') + appendField("reject_reason", record.rejectReason.wireName) + } append('}') } diff --git a/android/app/src/main/java/com/noop/protocol/BackfillCaptureSummary.kt b/android/app/src/main/java/com/noop/protocol/BackfillCaptureSummary.kt index 6ffffc5812..8ae614cafc 100644 --- a/android/app/src/main/java/com/noop/protocol/BackfillCaptureSummary.kt +++ b/android/app/src/main/java/com/noop/protocol/BackfillCaptureSummary.kt @@ -9,15 +9,32 @@ class BackfillCaptureSummary( val size: Int, val characteristic: String, val hex: String, + val rejectReason: FrameRejectReason, ) private val counts = linkedMapOf() private val unknownSamples = ArrayList() - fun record(typeName: String, crcOk: Boolean?, size: Int, characteristic: String, hex: String) { + /** + * Record one captured frame. [typeName] is kept exactly as the decoder read it, including for a + * frame the verifier rejected — an unmapped type is what the summary exists to surface, and a + * damaged envelope is no reason to stop naming it. + * + * [rejectReason] is ADDITIVE and defaulted, so the existing call shape stays valid. It says why the + * sample was rejected, which is the difference between "this firmware has a type we cannot map" and + * "this sample is a corrupted copy of a type we already know" — indistinguishable from the hex alone. + */ + fun record( + typeName: String, + crcOk: Boolean?, + size: Int, + characteristic: String, + hex: String, + rejectReason: FrameRejectReason = FrameRejectReason.NONE, + ) { counts[typeName] = (counts[typeName] ?: 0) + 1 if (unknownSamples.size < maxUnknownSamples && isUnknownType(typeName)) { - unknownSamples += UnknownSample(typeName, crcOk, size, characteristic, hex) + unknownSamples += UnknownSample(typeName, crcOk, size, characteristic, hex, rejectReason) } } @@ -35,7 +52,13 @@ class BackfillCaptureSummary( "none" } else { unknownSamples.joinToString("; ") { - "${it.typeName}(size=${it.size},char=${it.characteristic},crc=${it.crcOk},hex=${it.hex})" + // `reject=` appears only when the frame WAS rejected. An intact sample's line is + // unchanged, and a "reject=none" on every entry would train the eye to skip the field — + // the same rule the frame inspector's line follows. + val reject = + if (it.rejectReason == FrameRejectReason.NONE) "" else "reject=${it.rejectReason.wireName}," + "${it.typeName}(size=${it.size},char=${it.characteristic},crc=${it.crcOk}," + + "${reject}hex=${it.hex})" } } diff --git a/android/app/src/main/java/com/noop/protocol/BatteryPackInfo.kt b/android/app/src/main/java/com/noop/protocol/BatteryPackInfo.kt index 74fd6985dd..e79c293cde 100644 --- a/android/app/src/main/java/com/noop/protocol/BatteryPackInfo.kt +++ b/android/app/src/main/java/com/noop/protocol/BatteryPackInfo.kt @@ -25,34 +25,98 @@ object BatteryPackInfo { /** WHOOP 4.0 only: pack VOLTAGE in mV — a 4.0 has no fuel-gauge command, so its pack is read via * GET_EXTENDED_BATTERY_INFO (98) which reports voltage, not a charge %. null on 5/MG. */ val voltageMv: Int? = null, - ) + ) { + /** + * Whether this reading is safe to SHOW. + * + * The offsets here are an unvalidated candidate re-derived from two captures, so a wrong one + * would not fail — it would render a confident wrong number, which is the failure this project + * treats as worse than a blank. A fuel gauge is a percentage: anything outside 0..100 means the + * offset moved, and the caller must render nothing rather than the value. + */ + val displayable: Boolean + get() = present && socPct != null && socPct >= 0.0 && socPct <= 100.0 + } - /** Resp-cmd byte sits at [cmdOff] (10 on WHOOP 5/MG — the only family with a pack). Null when the - * frame is not a well-formed 151 SUCCESS response; the caller CRC-gates the frame (framing layer does). */ - fun decode(frame: ByteArray, cmdOff: Int = 10): Info? { - // Layout vs cmdOff, pinned to the captures: +0 resp-cmd (151), +2 result (1 = SUCCESS), +4 present - // flag, +5 BT address (6 bytes), +11 serial (16-byte ASCII, NUL-terminated), +27 SoC (u16 LE, - // tenths of a percent). - if (cmdOff < 0 || frame.size <= cmdOff + 4) return null - if ((frame[cmdOff].toInt() and 0xFF) != 151 || (frame[cmdOff + 2].toInt() and 0xFF) != 1) return null - val present = (frame[cmdOff + 4].toInt() and 0xFF) == 1 + /** + * Decode the pack RECORD itself, given the offset of its present-flag byte. + * + * Split out from [decode] because the same record reaches us by two entirely different transports: + * as the payload of a `GET_BATTERY_PACK_INFO` (151) COMMAND_RESPONSE, and — hardware-confirmed on + * WHOOP MG fw 50.39.1.0, 2026-09-01 — as the payload of the UNCATALOGUED pushed event `0x6D` (109), + * which the strap volunteers every couple of minutes while a pack is attached. Only the framing + * around the record differs; the fields are identical, so they must not be decoded twice. + * + * Layout from [base]: +0 present, +1 BT address (6 B), +7 serial (16 B ASCII, NUL-terminated), + * +23 SoC (u16 LE, tenths of a percent). Null when the buffer is too short to hold it. + */ + fun decodeRecord(bytes: ByteArray, base: Int): Info? { + if (base < 0 || bytes.size <= base) return null + val present = (bytes[base].toInt() and 0xFF) == 1 if (!present) return Info(false, null, null, null) - val btStart = cmdOff + 5 - val serStart = cmdOff + 11 - val socStart = cmdOff + 27 - if (frame.size < socStart + 2) return null + val btStart = base + 1 + val serStart = base + 7 + val socStart = base + 23 + if (bytes.size < socStart + 2) return null - val btAddr = (btStart until btStart + 6).joinToString("") { "%02x".format(frame[it].toInt() and 0xFF) } - val serBytes = (serStart until serStart + 16).map { frame[it] }.takeWhile { it.toInt() != 0 } + val btAddr = (btStart until btStart + 6).joinToString("") { "%02x".format(bytes[it].toInt() and 0xFF) } + val serBytes = (serStart until serStart + 16).map { bytes[it] }.takeWhile { it.toInt() != 0 } // Null on empty OR any non-ASCII byte, matching the Swift twin's `String(bytes:encoding:.ascii)` // (which returns nil for any byte > 127) — keeps the two byte-identical on a malformed serial. val serial = if (serBytes.isEmpty() || serBytes.any { (it.toInt() and 0xFF) >= 0x80 }) null else String(serBytes.toByteArray(), Charsets.US_ASCII) - val raw = (frame[socStart].toInt() and 0xFF) or ((frame[socStart + 1].toInt() and 0xFF) shl 8) + val raw = (bytes[socStart].toInt() and 0xFF) or ((bytes[socStart + 1].toInt() and 0xFF) shl 8) return Info(true, raw / 10.0, serial, btAddr) } + /** + * Decode the pack record from the pushed event `0x6D` (109) payload — the transport that actually + * works on a 5/MG. The strap sends this unprompted while a pack is attached, so it needs no command, + * no send-allowlist entry and no polling; the repeat cadence makes it a self-refreshing LEVEL signal + * that cannot go stale. The record's present flag sits at payload offset 4. + * + * Confirmed against three consecutive captures from one MG (serial WBB5AP0000001, SoC 57.1 % -> + * 56.9 % -> 56.7 % as the pack drained into the strap) — the falling trend is what establishes the + * SoC word as tenths of a percent rather than a plausible-looking constant. + */ + fun decodeEventPayload(payload: ByteArray): Info? = decodeRecord(payload, EVENT_RECORD_BASE) + + /** Offset of the present flag inside the event-109 payload. */ + const val EVENT_RECORD_BASE = 4 + + /** + * Decode straight from a whole 5/MG EVENT frame, so callers never have to know where the payload + * starts. A 5/MG event's opaque payload begins at frame offset 16 (`Framing.decodeEventWhoop5`), so + * the record's present flag lands at 16 + [EVENT_RECORD_BASE]. Keeping that arithmetic here means the + * BLE layer passes the frame it already has and no offset knowledge leaks out of the protocol package. + * + * Callers must check the event number is [PACK_INFO_EVENT] first — this does not re-check it, because + * the event byte lives in the frame header rather than the record. + */ + fun decodeEventFrame(frame: ByteArray): Info? = decodeRecord(frame, WHOOP5_EVENT_PAYLOAD_START + EVENT_RECORD_BASE) + + /** Where a WHOOP 5/MG EVENT frame's opaque payload begins. Mirrors `Framing.decodeEventWhoop5`. */ + const val WHOOP5_EVENT_PAYLOAD_START = 16 + + /** Uncatalogued 5/MG event that carries the pack record. Named here because `EventNumber` has no + * entry for it — it decodes as `0x6D(109)`. */ + const val PACK_INFO_EVENT = 109 + + /** Resp-cmd byte sits at [cmdOff] (10 on WHOOP 5/MG — the only family with a pack). Null when the + * frame is not a well-formed 151 SUCCESS response; the caller CRC-gates the frame (framing layer does). + * + * NOTE this command path is retained for completeness only. On WHOOP MG fw 50.39.1.0 opcode 151 + * answers FAILURE(0) whether or not a pack is attached, so the feature is driven by + * [decodeEventPayload] instead. See docs/DESIGN-battery-pack-charge.md. */ + fun decode(frame: ByteArray, cmdOff: Int = 10): Info? { + // Header, pinned to the captures: +0 resp-cmd (151), +2 result (1 = SUCCESS). The record then + // starts at +4, which is what [decodeRecord] expects as its base. + if (cmdOff < 0 || frame.size <= cmdOff + 4) return null + if ((frame[cmdOff].toInt() and 0xFF) != 151 || (frame[cmdOff + 2].toInt() and 0xFF) != 1) return null + return decodeRecord(frame, cmdOff + 4) + } + /** * WHOOP 4.0 path. A 4.0 has no `GET_BATTERY_PACK_INFO` (151); its pack is read via * `GET_EXTENDED_BATTERY_INFO` (98), which reports the pack VOLTAGE (mV) — NOT a charge %. Voltage at diff --git a/android/app/src/main/java/com/noop/protocol/DataRange.kt b/android/app/src/main/java/com/noop/protocol/DataRange.kt index cefd9ccf3e..d0cf8402f0 100644 --- a/android/app/src/main/java/com/noop/protocol/DataRange.kt +++ b/android/app/src/main/java/com/noop/protocol/DataRange.kt @@ -97,4 +97,41 @@ object DataRange { if (behind < 0L || behind > t) return null return behind } + + /** + * The BLE seam's decision on a GET_DATA_RANGE COMMAND_RESPONSE: is this a reply to [opcode], and may + * it move state? A reply is accepted only on the verifier's FULL verdict. + * + * This one reply does not merely report a timestamp: [newestUnix]/[oldestUnix] become the window every + * drained record of the same sync is checked against (#547). A damaged reply that narrows the window + * makes the real records fall through it — the section persists nothing and is acked anyway, which is + * the permanent loss the integrity gate exists to stop. So the gate belongs to this decision, not to + * the seam that happens to host it. + * + * [verdictOk] is the FULL verdict of the single parse the caller already holds, taken lazily so a frame + * that is not this reply costs one byte compare and no parse. The opcode is passed in rather than + * restated here: the command table lives with the caller. Mirrors Swift `DataRange.acceptsReply`. + */ + fun acceptsReply(frame: ByteArray, cmdOff: Int, opcode: Int, verdictOk: () -> Boolean): Boolean { + if (cmdOff < 0 || cmdOff >= frame.size) return false + if ((frame[cmdOff].toInt() and 0xFF) != opcode) return false + return verdictOk() + } + + /** + * True when a GET_DATA_RANGE COMMAND_RESPONSE is the `PENDING(2)` acknowledgement rather than the + * answer. The strap replies twice: a short PENDING ack, then the payload with `SUCCESS(1)`. Framing's + * own result-code table already states it — "2=PENDING precedes SUCCESS on GET_DATA_RANGE + * (hardware-confirmed, #78 fork)" — but [pagesBehind] had no way to tell the two apart, so the ack + * (which carries no ring pointers by construction) decoded to null and logged as a decode FAILURE once + * per sync. + * + * The result byte sits at `cmdOff + 2`, after the echoed opcode and the origin sequence. A frame too + * short to hold one is not a PENDING ack, so it stays false and the caller keeps its existing + * too-short handling. Twin of the Swift `DataRange.isPendingResponse`. + */ + fun isPendingResponse(frame: ByteArray, cmdOff: Int): Boolean { + if (cmdOff < 0 || cmdOff + 2 >= frame.size) return false + return (frame[cmdOff + 2].toInt() and 0xFF) == 2 + } } diff --git a/android/app/src/main/java/com/noop/protocol/EcgResearchAllowList.kt b/android/app/src/main/java/com/noop/protocol/EcgResearchAllowList.kt new file mode 100644 index 0000000000..9b34098810 --- /dev/null +++ b/android/app/src/main/java/com/noop/protocol/EcgResearchAllowList.kt @@ -0,0 +1,110 @@ +package com.noop.protocol + +/** + * #891 / #1100: the ALLOW-LIST that bounds what the MG ECG Research panel may put on the wire, and the + * named DENY-LIST of opcodes it must never be able to form. + * + * ## Why an allow-list, and why here + * + * The research panel exists to gather protocol evidence about the MG's ECG ("Labrador") subsystem — NOT to + * sweep the command space. The single most dangerous thing a hand-run ECG probe could do is wander into a + * neighbouring opcode: `TOGGLE_LABRADOR_FILTERED` is **139 (0x8B)**, and three codes above it sit + * **142/143/144 — `START_FIRMWARE_LOAD_NEW` / `LOAD_FIRMWARE_DATA_NEW` / `PROCESS_FIRMWARE_IMAGE_NEW`**, the + * destructive firmware-load family (`docs/PROTOCOL.md` §9.1). So this file states the *entire* set of + * opcodes the panel can dispatch as a literal, testable predicate, rather than trusting a UI never to build + * anything else. + * + * This mirrors the discipline the rest of the 5/MG send path already follows — `DeviceConfigWriteGate` + * (key-aware SET allow-list), `DeviceConfigReadProbe.isReadOnlyOpcode`, `FeatureFlagWriteGate`. Like those, + * it is a pure predicate the real BLE send path consults ([WhoopBleClient.ecgSendAdmitted] calls + * [isProbeOpcode]), so a unit test proving this rejects an opcode is proving it about the wire path and not + * a parallel copy of the rule. + * + * Pure: no Android, no I/O. Twin-free — Android is the platform growing an ECG *app* layer first (the Apple + * probe drives the same four opcodes but expresses its allow-list inline in `BLEManager.send`). + */ +object EcgResearchAllowList { + + // ---- The four ECG ("Labrador") probe opcodes — the ONLY commands the panel dispatches ---------- + + /** + * The four opcodes the panel may send, each a documented WORKING HYPOTHESIS (not a confirmed mapping — + * see `docs/PROTOCOL.md` §9.1). Every one is a plain toggle/config write with a `[revision, arg]` + * payload; none loads firmware, wipes flash, or resets a bond. Kept in [Whoop5Ecg] so the numbers live + * with the command builders and this set can never drift from what [Whoop5Ecg.commandFrame] forms. + */ + val PROBE_OPCODES: Set = setOf( + Whoop5Ecg.SELECT_WRIST_CMD, // 123 (0x7B) — PERSISTENT wrist config (own action) + Whoop5Ecg.TOGGLE_REALTIME_FILTERED_ECG_CMD, // 139 (0x8B) — live filtered stream toggle + Whoop5Ecg.TOGGLE_SAVE_RAW_ECG_CMD, // 125 (0x7D) — persist-to-flash toggle + Whoop5Ecg.MAIN_CONTROL_ECG_DATA_GENERATION_CMD, // 124 (0x7C) — start/stop generation + ) + + /** + * The two DEVICE-CONFIG opcodes the panel's persistent `enable_raw_data_w_ecg` gate uses — a WRITE + * (119) and its mandatory read-back (121). These are NOT dispatched as ECG commands; they go through + * the pre-existing, key-aware [DeviceConfigWriteGate] path ([WhoopBleClient.setEcgRawDataGate]). Named + * here only so a reader sees the panel's complete wire surface in one place. + */ + val GATE_OPCODES: Set = setOf( + DeviceConfigWriteGate.SET_DEVICE_CONFIG_VALUE_CMD, // 119 (0x77) + DeviceConfigWriteGate.GET_DEVICE_CONFIG_VALUE_CMD, // 121 (0x79) + ) + + /** True only for one of the four ECG probe opcodes. The predicate the BLE send allow-list consults. */ + fun isProbeOpcode(opcode: Int): Boolean = opcode in PROBE_OPCODES + + /** True for the two device-config gate opcodes (write + read-back). */ + fun isGateOpcode(opcode: Int): Boolean = opcode in GATE_OPCODES + + /** The complete set of opcodes reachable from the panel, ECG toggles plus the gate's two verbs. */ + val DISPATCHABLE_OPCODES: Set = PROBE_OPCODES + GATE_OPCODES + + // ---- The deny-list: opcodes this panel must NEVER be able to form (Phase 11) ------------------- + + /** + * Named dangerous / persistent-state / recovery-defeating opcodes the ECG research panel must never + * dispatch. This is defence-in-depth documentation, NOT the enforcement mechanism — enforcement is the + * positive allow-list ([isProbeOpcode]); nothing here can be sent because it is not in [PROBE_OPCODES], + * and most are not even in the curated [CommandNumber] SENDER enum so no builder can express them. + * + * Listed (rather than merely omitted) so a unit test can assert every one of them is refused by + * [isProbeOpcode] and is absent from [DISPATCHABLE_OPCODES] — the "no opcode sweep, no DFU" guarantee + * made checkable. Values are the schema numbers from [CommandNames]. + */ + val FORBIDDEN: Map = mapOf( + // Firmware load / DFU — the family that sits just above 139 in opcode space, and the reason an + // ECG sweep is dangerous. NOOP's curated CommandNumber enum omits all of these entirely. + 36 to "START_FIRMWARE_LOAD", + 37 to "LOAD_FIRMWARE_DATA", + 38 to "PROCESS_FIRMWARE_IMAGE", + 45 to "ENTER_BLE_DFU", + 83 to "VERIFY_FIRMWARE_IMAGE", + 142 to "START_FIRMWARE_LOAD_NEW", + 143 to "LOAD_FIRMWARE_DATA_NEW", + 144 to "PROCESS_FIRMWARE_IMAGE_NEW", + // Data-wipe / fuel-gauge / factory-adjacent. + 25 to "FORCE_TRIM", + 99 to "RESET_FUEL_GAUGE", + 100 to "CALIBRATE_CAPSENSE", + // Reboot / power-cycle — recovery is via the app's ordinary reconnect, never a strap reset from + // this panel. + 29 to "REBOOT_STRAP", + 32 to "POWER_CYCLE_STRAP", + // High-frequency sync entry — an uncertain, battery-heavy mode NOOP deliberately never enters + // (docs/PROTOCOL.md note on 96); the panel does not offer it. + 96 to "ENTER_HIGH_FREQ_SYNC", + 97 to "EXIT_HIGH_FREQ_SYNC", + // Optical / AFE / bias front-end writes — persistent sensor calibration, out of scope for ECG. + 39 to "SET_LED_DRIVE", + 41 to "SET_TIA_GAIN", + 43 to "SET_BIAS_OFFSET", + 61 to "SET_AFE_PARAMETERS", + ) + + /** True if [opcode] is a named-forbidden opcode. Used by the census test; never a dispatch input. */ + fun isForbidden(opcode: Int): Boolean = FORBIDDEN.containsKey(opcode) + + /** Schema label for any opcode, e.g. `"TOGGLE_LABRADOR_FILTERED(139)"`, for logs/reports. */ + fun label(opcode: Int): String = CommandNames.label(opcode) +} diff --git a/android/app/src/main/java/com/noop/protocol/EcgResearchLog.kt b/android/app/src/main/java/com/noop/protocol/EcgResearchLog.kt new file mode 100644 index 0000000000..b687088b6c --- /dev/null +++ b/android/app/src/main/java/com/noop/protocol/EcgResearchLog.kt @@ -0,0 +1,371 @@ +package com.noop.protocol + +/** + * #891 / #1100: the PURE in-memory record of one MG ECG research capture — every TX/RX/command/marker row, + * plus the serializers that turn them into the export bundle's CSV/JSON/README files. + * + * No Android, no I/O, no clock of its own: the caller ([com.noop.ble.EcgResearchSession]) supplies both a + * monotonic and a wall-clock timestamp for every row, so the whole model is deterministic and unit-tested + * without a strap or a device. The Android wrapper owns the file writing and the clocks; this owns the + * shape of the evidence. + * + * Volume: an ECG capture must NOT sample away high-rate packets (#891), so every inbound frame is kept. + * Appends are O(1) (a row holds raw bytes, hex-encoded only at serialize time) so they never block the BLE + * callback thread. A safety cap ([maxRxRows]) bounds worst-case memory; frames past it are COUNTED + * ([rxOverflow]) and reported in `stats.json`, never silently dropped. + * + * Not medical: this records protocol bytes and user-set physical markers. It never records or derives a + * clinical interpretation — the on-strap rhythm classifier byte is decoded elsewhere and never surfaced as + * a finding. + */ +class EcgResearchLog( + val sessionId: String, + val startWallMs: Long, + val startMonotonicMs: Long, + private val maxRxRows: Int = DEFAULT_MAX_RX_ROWS, + private val maxRxBytes: Long = DEFAULT_MAX_RX_BYTES, +) { + companion object { + /** ~ enough for many minutes of a high-rate stream; past this, frames are counted not stored. */ + const val DEFAULT_MAX_RX_ROWS = 200_000 + + /** + * Bytes of inbound payload a capture will hold. A row cap alone does not bound memory: 200,000 rows + * of 240-byte records is ~48 MB of payload before object overhead, and an offload can deliver frames + * far faster than a UI can notice, so a capture left running through a long sync could push the app + * to an OOM. Whichever cap is reached first stops storage; the drops are counted either way. + */ + const val DEFAULT_MAX_RX_BYTES = 24L * 1024 * 1024 + + /** #891 flagged a repeated 1,584-byte record as a candidate raw ECG block. */ + const val CANDIDATE_RAW_RECORD_SIZE = 1584 + + /** Samples `waveform.csv` will emit before truncating — and it SAYS it truncated, in the file. */ + const val WAVEFORM_CSV_SAMPLE_CAP = 60_000 + } + + // ---- Row types --------------------------------------------------------------------------------- + + /** An experimental command written to the strap (Phase 4 TX fields). */ + data class TxRow( + val monotonicMs: Long, + val wallMs: Long, + val opcode: Int, + val payload: ByteArray, + val frame: ByteArray, + val serviceUuid: String, + val characteristicUuid: String, + val writeType: String, + ) + + /** An inbound BLE notification/read during the capture (Phase 4 RX fields). */ + data class RxRow( + val monotonicMs: Long, + val wallMs: Long, + val serviceUuid: String, + val characteristicUuid: String, + val payload: ByteArray, + val packetType: Int?, + val sequence: Int?, + val recognized: Boolean, + val rejected: Boolean, + /** The frame's CRC verdict, or null when there was nothing to check (the house `crcOk != false` + * idiom). A CRC FAILURE is recorded, not dropped: for a research capture a corrupt frame is + * evidence, and dropping it would make "the strap sent nothing" indistinguishable from "the app threw it + * away". No decoder reads a row whose verdict is false. */ + val crcOk: Boolean? = null, + ) + + /** The result of one allow-listed experimental command: outcome + timing + raw ack. */ + data class CommandRow( + val monotonicMs: Long, + val wallMs: Long, + val opcode: Int, + val argHex: String, + val statusLevel: String, // Confirmed / Observed / Hypothesized + val persistence: String, // persistent / session + val ackResult: String?, // SUCCESS(1) etc. + val ackRawHex: String?, + val elapsedToAckMs: Long?, + ) + + /** A user marker or connection event (Phase 5). */ + data class EventRow( + val monotonicMs: Long, + val wallMs: Long, + val kind: String, + val detail: String, + ) + + /** A raw write-callback status (Phase 4 write callback status). */ + data class WriteStatusRow(val monotonicMs: Long, val wallMs: Long, val characteristicUuid: String, val status: Int) + + // ---- Storage ----------------------------------------------------------------------------------- + + private val lock = Any() + private val _tx = ArrayList() + private val _rx = ArrayList() + private val _commands = ArrayList() + private val _events = ArrayList() + private val _writeStatus = ArrayList() + + /** Inbound frames dropped by the [maxRxRows]/[maxRxBytes] safety caps (reported, never silent). */ + var rxOverflow: Long = 0L + private set + + /** Stored inbound payload bytes, against [maxRxBytes]. */ + var rxBytes: Long = 0L + private set + + val txRows: List get() = synchronized(lock) { _tx.toList() } + val rxRows: List get() = synchronized(lock) { _rx.toList() } + val commandRows: List get() = synchronized(lock) { _commands.toList() } + val eventRows: List get() = synchronized(lock) { _events.toList() } + + val txCount: Int get() = synchronized(lock) { _tx.size } + val rxCount: Int get() = synchronized(lock) { _rx.size } + val commandCount: Int get() = synchronized(lock) { _commands.size } + val eventCount: Int get() = synchronized(lock) { _events.size } + + // ---- Recording --------------------------------------------------------------------------------- + + fun recordTx(row: TxRow) = synchronized(lock) { _tx.add(row) } + + fun recordRx(row: RxRow) = synchronized(lock) { + if (_rx.size >= maxRxRows || rxBytes + row.payload.size > maxRxBytes) { rxOverflow++; return@synchronized } + _rx.add(row) + rxBytes += row.payload.size + } + + fun recordCommand(row: CommandRow) = synchronized(lock) { _commands.add(row) } + + fun recordEvent(row: EventRow) = synchronized(lock) { _events.add(row) } + + fun recordWriteStatus(row: WriteStatusRow) = synchronized(lock) { _writeStatus.add(row) } + + // ---- Derived stats input ----------------------------------------------------------------------- + + private fun rxRecords(): List = synchronized(lock) { + _rx.map { + EcgResearchStats.RxRecord( + monotonicMs = it.monotonicMs, + characteristic = shortUuid(it.characteristicUuid), + payload = it.payload, + packetType = it.packetType, + recognized = it.recognized, + ) + } + } + + // ---- Serializers ------------------------------------------------------------------------------- + + fun eventsCsv(): String { + val sb = StringBuilder("monotonic_ms,wall_iso,kind,detail\n") + for (e in eventRows) { + sb.append(e.monotonicMs).append(',').append(iso(e.wallMs)).append(',') + .append(csv(e.kind)).append(',').append(csv(e.detail)).append('\n') + } + return sb.toString() + } + + fun bleTxCsv(): String { + val sb = StringBuilder( + "monotonic_ms,wall_iso,opcode_dec,opcode_hex,payload_hex,frame_hex,service_uuid,char_uuid,write_type\n", + ) + for (t in txRows) { + sb.append(t.monotonicMs).append(',').append(iso(t.wallMs)).append(',') + .append(t.opcode).append(',').append(hex2(t.opcode)).append(',') + .append(hex(t.payload)).append(',').append(hex(t.frame)).append(',') + .append(csv(t.serviceUuid)).append(',').append(csv(t.characteristicUuid)).append(',') + .append(csv(t.writeType)).append('\n') + } + return sb.toString() + } + + fun bleRxCsv(): String { + val sb = StringBuilder( + "monotonic_ms,wall_iso,service_uuid,char_uuid,len,packet_type,sequence,crc_ok,recognized,rejected,payload_hex\n", + ) + for (r in rxRows) { + sb.append(r.monotonicMs).append(',').append(iso(r.wallMs)).append(',') + .append(csv(r.serviceUuid)).append(',').append(csv(r.characteristicUuid)).append(',') + .append(r.payload.size).append(',') + .append(r.packetType?.toString() ?: "").append(',') + .append(r.sequence?.toString() ?: "").append(',') + .append(r.crcOk?.toString() ?: "").append(',') + .append(r.recognized).append(',').append(r.rejected).append(',') + .append(hex(r.payload)).append('\n') + } + return sb.toString() + } + + fun commandsCsv(): String { + val sb = StringBuilder( + "monotonic_ms,wall_iso,opcode_dec,opcode_hex,command,arg_hex,status_level,persistence,ack_result,elapsed_ms,ack_raw_hex\n", + ) + for (c in commandRows) { + sb.append(c.monotonicMs).append(',').append(iso(c.wallMs)).append(',') + .append(c.opcode).append(',').append(hex2(c.opcode)).append(',') + .append(csv(CommandNames.label(c.opcode))).append(',').append(csv(c.argHex)).append(',') + .append(csv(c.statusLevel)).append(',').append(csv(c.persistence)).append(',') + .append(csv(c.ackResult ?: "")).append(',') + .append(c.elapsedToAckMs?.toString() ?: "").append(',') + .append(csv(c.ackRawHex ?: "")).append('\n') + } + return sb.toString() + } + + /** Only the RX rows the framing parser did NOT recognise — the unknown-packet corpus (Phase 6/7). */ + fun unknownPacketsCsv(): String { + val sb = StringBuilder("monotonic_ms,wall_iso,char_uuid,len,packet_type,crc_ok,payload_hex\n") + for (r in rxRows) { + if (r.recognized) continue + sb.append(r.monotonicMs).append(',').append(iso(r.wallMs)).append(',') + .append(csv(r.characteristicUuid)).append(',').append(r.payload.size).append(',') + .append(r.packetType?.toString() ?: "").append(',') + .append(r.crcOk?.toString() ?: "").append(',').append(hex(r.payload)).append('\n') + } + return sb.toString() + } + + /** + * `waveform.csv` — the i16 samples carried by each filled type-43 record, long format + * (record, monotonic_ms, sample_index, value). Non-diagnostic raw samples for plotting/analysis. + * + * The type-43 body opens with a constant 5×i16 sub-header at byte offsets 24..33; the waveform is the + * i16 LE series from offset 34 to 236. Empty (baseline) records are skipped. Capped so one long capture + * can't produce an unbounded file. + */ + fun waveformCsv(): String { + val sb = StringBuilder("record,monotonic_ms,sample_index,value\n") + var rec = 0 + var total = 0 + val cap = WAVEFORM_CSV_SAMPLE_CAP + for (r in rxRows) { + if (r.crcOk == false) continue // never plot bytes that failed CRC + val vals = Whoop5Ecg.realtimeRawSamples(r.payload) ?: continue + // A record of all zeros is baseline, not waveform, and would swamp the file. A record that has + // ANY content is written in full, zeros included: trimming a trailing zero run (as this once did) + // silently edits the evidence, and a run of zeros at the end of a real record is itself a fact. + if (vals.all { it == 0 }) continue + for ((idx, v) in vals.withIndex()) { + sb.append(rec).append(',').append(r.monotonicMs).append(',').append(idx).append(',').append(v).append('\n') + if (++total >= cap) { + sb.append("# truncated at ").append(cap).append(" samples\n") + return sb.toString() + } + } + rec++ + } + return sb.toString() + } + + /** `stats.json` — non-diagnostic protocol statistics for a maintainer. */ + fun statsJson(): String { + val records = rxRecords() + val pps = EcgResearchStats.packetsPerSecondByCharacteristic(records) + val hist = EcgResearchStats.payloadSizeHistogram(records) + val largest = EcgResearchStats.largestPayloadSizes(records, 8) + val typeCounts = EcgResearchStats.packetTypeCounts(records) + val repeatedSizes = hist.filterValues { it >= 3 } + val candidateRawCount = EcgResearchStats.countOfSize(records, CANDIDATE_RAW_RECORD_SIZE) + val maxZero = EcgResearchStats.maxZeroFractionOfLargeRecords(records, 64) + // "appeared after Start" — measured around the first command-sequence marker if the run set one. + val startMarker = eventRows.firstOrNull { it.kind == "command_sequence_started" }?.monotonicMs + val delta = startMarker?.let { EcgResearchStats.typeDeltaAround(records, it) } + // A changing-position map for the most common large record size (candidate counter/timestamp fields). + val dominantLarge = hist.entries.filter { it.key >= 64 }.maxByOrNull { it.value }?.key + val changing = dominantLarge?.let { size -> + EcgResearchStats.changingPositionsAcross( + synchronized(lock) { _rx.filter { it.payload.size == size }.map { it.payload } }, + ) + } + + val sb = StringBuilder() + sb.append("{\n") + sb.append(" \"sessionId\": ").append(jstr(sessionId)).append(",\n") + sb.append(" \"startWallIso\": ").append(jstr(iso(startWallMs))).append(",\n") + sb.append(" \"counts\": {") + sb.append("\"tx\": ").append(txCount).append(", \"rx\": ").append(rxCount) + sb.append(", \"commands\": ").append(commandCount).append(", \"events\": ").append(eventCount) + sb.append(", \"unknownRx\": ").append(EcgResearchStats.unrecognizedCount(records)) + sb.append(", \"rxOverflowDropped\": ").append(rxOverflow).append("},\n") + sb.append(" \"packetsPerSecondByCharacteristic\": ").append(jnumMap(pps)).append(",\n") + sb.append(" \"payloadSizeHistogram\": ").append(jintMap(hist.mapKeys { it.key.toString() })).append(",\n") + sb.append(" \"largestPayloadSizes\": ").append(jintList(largest)).append(",\n") + sb.append(" \"packetTypeCounts\": ") + .append(jintMap(typeCounts.mapKeys { it.key?.toString() ?: "unrecognized" })).append(",\n") + sb.append(" \"repeatedSizes\": ").append(jintMap(repeatedSizes.mapKeys { it.key.toString() })).append(",\n") + sb.append(" \"candidateRawRecordSize\": ").append(CANDIDATE_RAW_RECORD_SIZE).append(",\n") + sb.append(" \"candidateRawRecordCount\": ").append(candidateRawCount).append(",\n") + sb.append(" \"maxZeroFractionOfLargeRecords\": ").append(maxZero?.let { round3(it).toString() } ?: "null").append(",\n") + sb.append(" \"changingBytePositionsForSize\": ") + .append(dominantLarge?.toString() ?: "null").append(",\n") + sb.append(" \"changingBytePositions\": ").append(jintList(changing ?: emptyList())).append(",\n") + sb.append(" \"packetTypesAppearedAfterStart\": ") + .append(jNullableIntList(delta?.appearedAfter)).append(",\n") + sb.append(" \"packetTypesDisappearedAfterStop\": ") + .append(jNullableIntList(delta?.disappearedAfter)).append("\n") + sb.append("}\n") + return sb.toString() + } + + // ---- formatting helpers (pure) ----------------------------------------------------------------- + + /** ISO-8601 UTC to the second, computed WITHOUT java.time.Instant.now so it is deterministic. */ + private fun iso(wallMs: Long): String { + var days = Math.floorDiv(wallMs, 86_400_000L) + val msOfDay = Math.floorMod(wallMs, 86_400_000L) + val hh = (msOfDay / 3_600_000L).toInt() + val mm = ((msOfDay % 3_600_000L) / 60_000L).toInt() + val ss = ((msOfDay % 60_000L) / 1000L).toInt() + // civil-from-days (Howard Hinnant's algorithm), epoch 1970-01-01. + var z = days + 719_468L + val era = (if (z >= 0) z else z - 146_096) / 146_097 + val doe = z - era * 146_097 + val yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365 + val y = yoe + era * 400 + val doy = doe - (365 * yoe + yoe / 4 - yoe / 100) + val mp = (5 * doy + 2) / 153 + val d = (doy - (153 * mp + 2) / 5 + 1).toInt() + val m = (if (mp < 10) mp + 3 else mp - 9).toInt() + val year = (if (m <= 2) y + 1 else y).toInt() + return "%04d-%02d-%02dT%02d:%02d:%02dZ".format(year, m, d, hh, mm, ss) + } + + private fun csv(s: String): String = + if (s.any { it == ',' || it == '"' || it == '\n' || it == '\r' }) { + "\"" + s.replace("\"", "\"\"") + "\"" + } else s + + private fun jstr(s: String): String { + val sb = StringBuilder("\"") + for (c in s) when (c) { + '"' -> sb.append("\\\"") + '\\' -> sb.append("\\\\") + '\n' -> sb.append("\\n") + '\r' -> sb.append("\\r") + '\t' -> sb.append("\\t") + else -> if (c < ' ') sb.append("\\u%04x".format(c.code)) else sb.append(c) + } + return sb.append('"').toString() + } + + private fun jintList(xs: List): String = xs.joinToString(", ", "[", "]") + private fun jNullableIntList(xs: List?): String = + (xs ?: emptyList()).joinToString(", ", "[", "]") { it?.toString() ?: "null" } + private fun jintMap(m: Map): String = + m.entries.joinToString(", ", "{", "}") { "${jstr(it.key)}: ${it.value}" } + private fun jnumMap(m: Map): String = + m.entries.joinToString(", ", "{", "}") { "${jstr(it.key)}: ${round3(it.value)}" } + private fun round3(d: Double): Double = Math.round(d * 1000.0) / 1000.0 + + private fun hex2(v: Int): String = "0x%02X".format(v and 0xFF) + private fun hex(b: ByteArray): String = b.joinToString("") { "%02x".format(it.toInt() and 0xFF) } + + /** Last 4 hex of a 128-bit UUID, or the string itself if short — the compact char label stats use. */ + private fun shortUuid(u: String): String { + val first = u.substringBefore('-') + return if (first.length >= 4) first.takeLast(4) else u + } +} diff --git a/android/app/src/main/java/com/noop/protocol/EcgResearchStats.kt b/android/app/src/main/java/com/noop/protocol/EcgResearchStats.kt new file mode 100644 index 0000000000..c1be24885d --- /dev/null +++ b/android/app/src/main/java/com/noop/protocol/EcgResearchStats.kt @@ -0,0 +1,256 @@ +package com.noop.protocol + +/** + * #891 / #1100: PURE, NON-DIAGNOSTIC protocol statistics over a captured MG ECG research session. + * + * Everything here is a shape/timing observation about bytes on a wire — packet rates, payload sizes, + * zero-fill, which byte positions move between records. NOTHING here interprets a waveform, and the + * vocabulary is deliberately neutral: a fast integer stream is a "candidate high-rate signal", never "the + * ECG". Provenance of any waveform is a question for a human reading the raw bytes, not a statistic. + * + * Pure (no Android, no I/O, no clock): every input is passed in, so the whole file is unit-tested without a + * strap. Consumed by [EcgResearchLog] to build `stats.json` for the export bundle. + */ +object EcgResearchStats { + + /** One inbound frame as the stats see it. [payload] is the on-wire bytes; [packetType] is the decoded + * type byte when the framing parser named one, else null; [recognized] is whether the parser mapped it. */ + data class RxRecord( + val monotonicMs: Long, + val characteristic: String, + val payload: ByteArray, + val packetType: Int?, + val recognized: Boolean, + ) { + val size: Int get() = payload.size + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is RxRecord) return false + return monotonicMs == other.monotonicMs && characteristic == other.characteristic && + packetType == other.packetType && recognized == other.recognized && + payload.contentEquals(other.payload) + } + + override fun hashCode(): Int { + var h = monotonicMs.hashCode() + h = 31 * h + characteristic.hashCode(); h = 31 * h + (packetType ?: -1) + h = 31 * h + recognized.hashCode(); h = 31 * h + payload.contentHashCode() + return h + } + } + + /** Fraction (0..1) of bytes in [payload] between [from], inclusive, and [to], exclusive, that are 0x00. + * An out-of-range or empty window returns 0.0. */ + fun zeroByteFraction(payload: ByteArray, from: Int = 0, to: Int = payload.size): Double { + val lo = from.coerceIn(0, payload.size) + val hi = to.coerceIn(lo, payload.size) + if (hi <= lo) return 0.0 + var zeros = 0 + for (i in lo until hi) if (payload[i].toInt() == 0) zeros++ + return zeros.toDouble() / (hi - lo) + } + + /** True when every byte in [payload] over [from, to) is 0x00 (a genuinely empty region — the "is the + * big raw area all zeros?" question #891 asks of large records). Empty window is NOT all-zero. */ + fun isAllZero(payload: ByteArray, from: Int = 0, to: Int = payload.size): Boolean { + val lo = from.coerceIn(0, payload.size) + val hi = to.coerceIn(lo, payload.size) + if (hi <= lo) return false + for (i in lo until hi) if (payload[i].toInt() != 0) return false + return true + } + + /** Byte positions where [a] and [b] differ (shared prefix only). A SEQUENCE-COUNTER hunt: a field that + * changes between consecutive same-shape records is a counter/timestamp candidate. */ + fun changingBytePositions(a: ByteArray, b: ByteArray): List { + val n = minOf(a.size, b.size) + val out = ArrayList() + for (i in 0 until n) if (a[i] != b[i]) out.add(i) + return out + } + + /** Union of positions that EVER change across a run of same-length records (the counter/timestamp map). */ + fun changingPositionsAcross(records: List): List { + if (records.size < 2) return emptyList() + val changing = sortedSetOf() + for (i in 1 until records.size) changing.addAll(changingBytePositions(records[i - 1], records[i])) + return changing.toList() + } + + /** packets/sec per characteristic across the whole window (count ÷ span seconds; a single packet or a + * zero span reports the raw count as a per-second rate so the number is never divided by zero). */ + fun packetsPerSecondByCharacteristic(records: List): Map { + val byChar = records.groupBy { it.characteristic } + return byChar.mapValues { (_, rs) -> + if (rs.size < 2) rs.size.toDouble() + else { + val span = (rs.maxOf { it.monotonicMs } - rs.minOf { it.monotonicMs }).coerceAtLeast(1) + rs.size.toDouble() * 1000.0 / span + } + } + } + + /** Payload-size histogram: exact size in bytes → how many records had it. */ + fun payloadSizeHistogram(records: List): Map { + val h = HashMap() + for (r in records) h[r.size] = (h[r.size] ?: 0) + 1 + return h.toSortedMap() + } + + /** The [n] largest distinct payload sizes seen, descending. */ + fun largestPayloadSizes(records: List, n: Int = 5): List = + records.map { it.size }.distinct().sortedDescending().take(n) + + /** Count of records whose payload is exactly [size] bytes — e.g. the repeated 1,584-byte record #891 + * flags as a candidate raw block. */ + fun countOfSize(records: List, size: Int): Int = records.count { it.size == size } + + /** Packet-type census: decoded type byte (or null for unrecognised) → count. */ + fun packetTypeCounts(records: List): Map { + val h = LinkedHashMap() + for (r in records) h[r.packetType] = (h[r.packetType] ?: 0) + 1 + return h + } + + /** How many records the framing parser did NOT recognise — the unknown/rejected tally (Phase 6/7). */ + fun unrecognizedCount(records: List): Int = records.count { !it.recognized } + + /** + * Packet types present only AFTER a boundary time [afterMs] (candidate "appeared after Start") and those + * present only BEFORE it (candidate "disappeared after Stop"). Type identity is the decoded type byte; + * null (unrecognised) is folded in as its own bucket. Purely descriptive — never asserts causation. + */ + data class TypeDelta(val appearedAfter: List, val disappearedAfter: List) + + fun typeDeltaAround(records: List, afterMs: Long): TypeDelta { + val before = records.filter { it.monotonicMs < afterMs }.map { it.packetType }.toSet() + val after = records.filter { it.monotonicMs >= afterMs }.map { it.packetType }.toSet() + return TypeDelta( + appearedAfter = (after - before).toList(), + disappearedAfter = (before - after).toList(), + ) + } + + /** Largest zero-byte fraction over any record at least [minSize] bytes — surfaces the "large records are + * mostly padding" observation. Returns null when no record is that big. */ + fun maxZeroFractionOfLargeRecords(records: List, minSize: Int = 64): Double? { + val big = records.filter { it.size >= minSize } + if (big.isEmpty()) return null + return big.maxOf { zeroByteFraction(it.payload) } + } + + /** + * Estimate beats-per-minute from a waveform by normalised autocorrelation — NON-DIAGNOSTIC, and + * deliberately willing to return null. + * + * ## The trap this is written around (#194) + * + * The strap's raw records carry a FIXED sample count ([Whoop5Ecg.SAMPLES_PER_RAW_RECORD] = 101). Anything + * that repeats per record — a constant sub-header, a re-sent block, a seam where two records join — is + * periodic at the record period, and at 100 Hz that period is 1.01 s == 59.4 bpm: inside this search band + * and inside resting heart-rate range. An autocorrelation fed a concatenated record stream will therefore + * report a confident ~59 bpm out of pure framing, with no cardiac content at all. That is the exact + * [excludeLag] is REQUIRED and has no default, which is the whole point of it. Passing the record + * period switches the guard on; passing null says "this source is not record-tiled" and accepts an + * unguarded estimate. Both are defensible, but only as a decision somebody made: with a default the + * shortest call, `estimateBpm(samples, fs)`, silently selected the unguarded form, and on a tiled + * 101-sample record at 100 Hz that form returns 59 — a believable resting rate manufactured from a + * buffer holding no rhythm at all. That is #194 exactly, and a wrong number nobody chose is the + * failure this whole method is shaped around. Requiring the argument costs one word at each call site + * and makes the unguarded path impossible to take by accident. + * + * failure mode behind NOOP's withdrawn PPG->HR estimate, so when [excludeLag] is supplied: + * + * 1. lags within [excludeLagTolerance] of it cannot win, and + * 2. the winner must also beat the strength AT the artefact lag ([artefactDominance]). Step 1 alone is + * not enough — a broad peak centred on the artefact lag still has strong shoulders just outside the + * notch, so a notch by itself just reports the artefact as a slightly different number. + * + * The cost is stated rather than hidden: a genuine heart rate that lands in that narrow band is + * indistinguishable from the artefact and is therefore DECLINED. Null means "this method cannot tell", + * never "no rhythm". + * + * ## Two things that made an earlier version wrong + * + * - **Comparability across lags.** A raw lag product sums over `n - lag` terms, so longer lags accumulate + * fewer terms and score lower for arithmetic rather than physiological reasons. Each lag is scored as a + * true normalised cross-correlation over its own overlap, so lags are comparable and [minStrength] + * means the same thing everywhere. + * - **Octave errors.** A periodic signal correlates just as well at twice its period, and with a + * non-integer number of cycles the double-period lag can score marginally higher — reporting 55 bpm for + * a 110 bpm input. So the winner is the SHORTEST lag within [octaveTolerance] of the best score, not + * the argmax. + */ + fun estimateBpm( + samples: IntArray, + fs: Int = 100, + minBpm: Int = 40, + maxBpm: Int = 180, + minStrength: Double = 0.3, + excludeLag: Int?, + excludeLagTolerance: Int = 2, + octaveTolerance: Double = 0.85, + artefactDominance: Double = 1.0, + ): Int? { + if (fs <= 0 || samples.size < fs) return null // need at least ~1 second + val n = samples.size + val mean = samples.average() + val c = DoubleArray(n) { samples[it] - mean } + var e0 = 0.0 + for (v in c) e0 += v * v + if (e0 <= 0.0) return null // a flat trace has no period to find + val loLag = (60.0 * fs / maxBpm).toInt().coerceAtLeast(1) + val hiLag = (60.0 * fs / minBpm).toInt().coerceAtMost(n - 1) + if (hiLag <= loLag) return null + + /** True normalised cross-correlation of the series against itself at [lag], over their overlap. */ + fun ncc(lag: Int): Double { + val overlap = n - lag + if (overlap <= 1) return 0.0 + var num = 0.0 + var eA = 0.0 + var eB = 0.0 + for (i in 0 until overlap) { + val a = c[i] + val b = c[i + lag] + num += a * b + eA += a * a + eB += b * b + } + if (eA <= 0.0 || eB <= 0.0) return 0.0 + return num / kotlin.math.sqrt(eA * eB) + } + + fun notched(lag: Int) = excludeLag != null && kotlin.math.abs(lag - excludeLag) <= excludeLagTolerance + + val strength = DoubleArray(hiLag + 1) + var best = Double.NEGATIVE_INFINITY + for (lag in loLag..hiLag) { + strength[lag] = ncc(lag) + if (!notched(lag) && strength[lag] > best) best = strength[lag] + } + if (best < minStrength) return null + + // Step 2 of the artefact guard: if the record period itself correlates at least as well as the best + // candidate outside the notch, the candidate is that peak's shoulder and not an independent rhythm. + if (excludeLag != null) { + var artefact = Double.NEGATIVE_INFINITY + for (lag in loLag..hiLag) if (notched(lag) && strength[lag] > artefact) artefact = strength[lag] + if (artefact != Double.NEGATIVE_INFINITY && best < artefact * artefactDominance) return null + } + + // Octave correction, over PEAKS only. Taking the shortest lag that merely clears the threshold picks + // a point on the rising shoulder of the real peak instead of the peak: a 45 bpm input (lag 133) has + // correlation 0.86 at lag 122, which cleared the floor and reported 49 bpm. A candidate must be a + // local maximum, and then the shortest such peak is the fundamental rather than a sub-harmonic. + val floor = best * octaveTolerance + for (lag in loLag..hiLag) { + if (notched(lag) || strength[lag] < floor) continue + val risingInto = lag == loLag || strength[lag] >= strength[lag - 1] + val fallingAfter = lag == hiLag || strength[lag] >= strength[lag + 1] + if (risingInto && fallingAfter) return (60.0 * fs / lag).toInt() + } + return null + } +} diff --git a/android/app/src/main/java/com/noop/protocol/Enums.kt b/android/app/src/main/java/com/noop/protocol/Enums.kt index 0e9ef0117c..9e4d348d9a 100644 --- a/android/app/src/main/java/com/noop/protocol/Enums.kt +++ b/android/app/src/main/java/com/noop/protocol/Enums.kt @@ -215,6 +215,10 @@ enum class CommandNumber(val rawValue: Int) { SET_DEVICE_CONFIG(119), START_RAW_DATA(81), STOP_RAW_DATA(82), + // WHOOP 5/MG realtime 6-axis IMU. The production WHOOP app sends revision 1 plus an enabled + // byte while a Strength Builder workout is active (hardware-captured on a normal WHOOP 5). + // NOOP exposes this only through the explicitly user-started Ground Truth collector. + TOGGLE_IMU_MODE(106), // GET_EXTENDED_BATTERY_INFO (98) — read-only extended battery read (mV etc.). The NUMBER is disputed // (#592): an independent APK decompile reads this family 11 lower (87), while whoomp's table says 98 — // partially supported by a real WHOOP 5 (fw 50.38.1.0) ANSWERING 98, though with a short stub that @@ -223,6 +227,8 @@ enum class CommandNumber(val rawValue: Int) { // dumped in full to the strap log so a normal export settles which number this firmware serves. // Mirrors Swift WhoopCommand.getExtendedBatteryInfo. GET_EXTENDED_BATTERY_INFO(98), + /** The 5/MG battery pack's fuel gauge, read THROUGH the strap. A 4.0 never answers it. */ + GET_BATTERY_PACK_INFO(151), // #690: read-only body-location/status probe. Documented in the WHOOP protocol; driven only by the // user-triggered, Test-Centre-gated probeBodyLocationAndStatus(). Decoded to a diagnostic report only. GET_BODY_LOCATION_AND_STATUS(84), diff --git a/android/app/src/main/java/com/noop/protocol/Framing.kt b/android/app/src/main/java/com/noop/protocol/Framing.kt index 9045b5f6ef..70c14032fa 100644 --- a/android/app/src/main/java/com/noop/protocol/Framing.kt +++ b/android/app/src/main/java/com/noop/protocol/Framing.kt @@ -21,20 +21,58 @@ package com.noop.protocol */ // MARK: - little-endian readers (null when out of range; mirror interpreter._read) +// +// [limit] is the EXCLUSIVE upper bound for a NAMED INNER FIELD (D7): the minimum of where the CRC32 +// trailer starts and how many bytes the frame actually has. It is a required argument, not a +// defaulted one, so adding a field read without deciding its bound does not compile. Callers derive +// it with [payloadLimitOf], which never returns more than the frame size, so passing it is at least +// as safe as the old size-only bound and never reads a byte that is not there. + +private fun ByteArray.u8(off: Int, limit: Int): Int? = + if (off >= 0 && off + 1 <= limit) this[off].toInt() and 0xFF else null + +private fun ByteArray.u16(off: Int, limit: Int): Int? = + if (off >= 0 && off + 2 <= limit) (this[off].toInt() and 0xFF) or ((this[off + 1].toInt() and 0xFF) shl 8) + else null + +private fun ByteArray.u32(off: Int, limit: Int): Long? { + if (off < 0 || off + 4 > limit) return null + return (this[off].toLong() and 0xFFL) or + ((this[off + 1].toLong() and 0xFFL) shl 8) or + ((this[off + 2].toLong() and 0xFFL) shl 16) or + ((this[off + 3].toLong() and 0xFFL) shl 24) +} -private fun ByteArray.u8(off: Int): Int? = if (off + 1 <= size) this[off].toInt() and 0xFF else null - -private fun ByteArray.u16(off: Int): Int? = - if (off + 2 <= size) (this[off].toInt() and 0xFF) or ((this[off + 1].toInt() and 0xFF) shl 8) else null - -private fun ByteArray.u32(off: Int): Long? { - if (off + 4 > size) return null +/** + * The frame's OWN envelope words (declared length, CRC32 trailer) — bounded by the array itself, not + * by [payloadLimitOf]. These bytes are the envelope, never a decoded inner field, so D7 does not + * apply to them; keeping them on a separate reader is what stops a field read borrowing the wider + * bound by accident. + */ +private fun ByteArray.envU32(off: Int): Long? { + if (off < 0 || off + 4 > size) return null return (this[off].toLong() and 0xFFL) or ((this[off + 1].toLong() and 0xFFL) shl 8) or ((this[off + 2].toLong() and 0xFFL) shl 16) or ((this[off + 3].toLong() and 0xFFL) shl 24) } +/** + * The exclusive upper bound for reading named inner fields out of [frame] (D7). + * + * It is the MINIMUM of the CRC32 trailer's start and the frame's real size — not one or the other. + * The trailer start follows from the DECLARED length, which on a truncated frame points past the + * last byte we hold, so using it alone would read off the end of the array; using only the frame + * size is what let a frame at the family minimum have its own checksum trailer decoded as a + * sequence number, a command byte or a metadata type. A field counts as present when its start plus + * its length does not EXCEED this bound: the smallest real WHOOP 4.0 history frame is 11 bytes with + * its trailer at 7, and its metadata type occupies precisely the last payload byte. + */ +private fun payloadLimitOf(frame: ByteArray, trailerStart: Int?): Int { + if (trailerStart == null) return frame.size + return minOf(maxOf(0, trailerStart), frame.size) +} + /** * Accumulate BLE notification fragments into complete frames. * @@ -53,6 +91,15 @@ class Reassembler(private val family: DeviceFamily = DeviceFamily.WHOOP4) { private var head = 0 // index of the first byte not yet consumed private var tail = 0 // index one past the last valid byte + /** + * How many start-of-frame bytes were dropped because the total length they declared was below + * the family minimum. Such a byte run never reaches a parser, so without this counter it would + * vanish without trace — and one of the readers downstream exists to preserve exactly the frames + * nothing else can read. Monotonic for the lifetime of the reassembler; [reset] leaves it alone. + */ + var belowMinimumLengthDrops = 0 + private set + /** * Drop any partial-frame remnant. Called on (re)connect so a stalled or garbage frame from one * session can't wedge the live stream in the next. The macOS BLEManager achieves the same by @@ -88,6 +135,14 @@ class Reassembler(private val family: DeviceFamily = DeviceFamily.WHOOP4) { } else { ((data[head + 1].toInt() and 0xFF) or ((data[head + 2].toInt() and 0xFF) shl 8)) + 4 } + if (total < FrameLimits.minimumFrameBytes(family)) { + // A declared total below the family minimum cannot be a frame: emitting it would hand + // a parser a byte run whose "inner fields" are its own checksum trailer. Drop this + // 0xAA, count it, and resync on the next one — same shape as the ceiling below. + belowMinimumLengthDrops += 1 + head += 1 + continue + } if (total > MAX_FRAME_BYTES) { // A corrupt or misaligned SOF decodes an impossibly large length and we'd wait forever // for bytes that can never arrive over BLE — the live stream would freeze until a @@ -145,12 +200,100 @@ class Reassembler(private val family: DeviceFamily = DeviceFamily.WHOOP4) { } } -/** Outcome of validating a frame envelope and its CRCs. */ -private data class FrameCheck( +/** + * Why a frame failed the envelope check — one value, never null, so a consumer can report the cause + * without verifying or parsing the frame a second time (the parse-once invariant). + * + * [NONE] is the ONLY value that accompanies a positive verdict. The two payload-CRC cases are kept + * apart on purpose: a diagnostic may only assert what it observed, and "we could not compute the + * CRC32" is not the same claim as "the CRC32 disagreed". Twin of the Swift `FrameRejectReason`, + * value for value (`none` → [NONE], `noStartOfFrame` → [NO_START_OF_FRAME], … ). + */ +enum class FrameRejectReason { + /** The frame is intact: header checksum, payload CRC32 and the structural length all agree. */ + NONE, + + /** No 0xAA start-of-frame byte — this byte run is not a frame at all. */ + NO_START_OF_FRAME, + + /** Fewer bytes than the device family's smallest well-formed frame can have. */ + BELOW_MINIMUM_LENGTH, + + /** + * The byte count does not equal the total derived from the declared length field: the frame is + * truncated, or it carries trailing bytes past its own end. + */ + LENGTH_MISMATCH, + + /** The header checksum (CRC-8 on WHOOP 4.0, CRC-16-Modbus on WHOOP 5.0/MG) disagreed. */ + HEADER_CHECKSUM_MISMATCH, + + /** The payload CRC32 was computed and disagreed. */ + PAYLOAD_CRC_MISMATCH, + + /** + * The payload CRC32 could not be computed at all. Fail-closed: an unverifiable CRC counts as a + * negative verdict, never as "unknown". The structural rules above make this unreachable for a + * frame that got past them, so it is defence in depth rather than an observed class. + */ + PAYLOAD_CRC_UNVERIFIABLE, +} + +/** + * The structural lower bounds a frame must clear before any of its bytes are read as fields. + * + * WHOOP 4.0: `[SOF][len u16][crc8][type][seq][cmd] + >=1 payload byte + [crc32 u32]` = 11 bytes. + * WHOOP 5.0/MG: `[SOF][fmt][declLen u16][hdr u16][crc16 u16] + >=1 payload byte + [crc32 u32]` = 13. + * The smallest real frame in the project's captures is exactly 11 bytes (WHOOP 4.0) and 124 bytes + * (WHOOP 5.0/MG), so neither bound rejects a recorded frame. Twin of the Swift `FrameLimits`. + */ +object FrameLimits { + const val WHOOP4_MINIMUM_FRAME_BYTES = 11 + const val WHOOP5_MINIMUM_FRAME_BYTES = 13 + + /** The minimum total frame size for [family], in bytes. */ + fun minimumFrameBytes(family: DeviceFamily): Int = when (family) { + DeviceFamily.WHOOP4 -> WHOOP4_MINIMUM_FRAME_BYTES + DeviceFamily.WHOOP5 -> WHOOP5_MINIMUM_FRAME_BYTES + } +} + +/** + * Turn the individual outcomes into ONE verdict and ONE reason, in a fixed order: structure first + * (a length we cannot trust makes every later read meaningless), then the header checksum, then the + * payload CRC32. A null [crc32Ok] is a rejection, not an "unknown" that a consumer might read as a + * pass — that tri-state is exactly what let malformed frames drive live state before this change. + */ +private fun frameRejectReason( + totalFromLength: Int, + actualCount: Int, + minimumBytes: Int, + headerCrcOk: Boolean, + crc32Ok: Boolean?, +): FrameRejectReason { + if (totalFromLength < minimumBytes) return FrameRejectReason.BELOW_MINIMUM_LENGTH + if (totalFromLength != actualCount) return FrameRejectReason.LENGTH_MISMATCH + if (!headerCrcOk) return FrameRejectReason.HEADER_CHECKSUM_MISMATCH + return when (crc32Ok) { + null -> FrameRejectReason.PAYLOAD_CRC_UNVERIFIABLE + false -> FrameRejectReason.PAYLOAD_CRC_MISMATCH + true -> FrameRejectReason.NONE + } +} + +/** + * Outcome of validating a frame envelope and its CRCs. + * + * [ok] is the FULL verdict: start-of-frame, minimum length, exact length, header checksum and + * payload CRC32 together. It is true exactly when [reason] is [FrameRejectReason.NONE]. The + * individual outcomes stay on the result as diagnostics. + */ +data class FrameCheck( val ok: Boolean, val length: Int? = null, val headerCrcOk: Boolean? = null, val crc32Ok: Boolean? = null, + val reason: FrameRejectReason = FrameRejectReason.NONE, ) object Framing { @@ -158,23 +301,51 @@ object Framing { // MARK: - validation /** - * Validate a complete Whoop 4.0 frame envelope and both CRCs. + * Validate a complete Whoop 4.0 frame envelope: structure, header checksum and payload CRC32. * Frame: [0xAA][len u16 LE][crc8(len)][...inner...][crc32 u32 LE], total = len + 4. + * + * A frame is accepted only when it is at least [FrameLimits.WHOOP4_MINIMUM_FRAME_BYTES] long, + * carries EXACTLY `len + 4` bytes (so a truncated frame and one with trailing bytes are both + * rejected), its CRC-8 over the length field matches, and its CRC32 over the inner record + * matches. `reason` says which rule failed first. */ private fun verifyWhoop4(frame: ByteArray): FrameCheck { - if (frame.size < 8 || frame[0] != 0xAA.toByte()) return FrameCheck(ok = false) + if (frame.isEmpty() || frame[0] != 0xAA.toByte()) { + return FrameCheck(ok = false, reason = FrameRejectReason.NO_START_OF_FRAME) + } + if (frame.size < FrameLimits.WHOOP4_MINIMUM_FRAME_BYTES) { + // Too short even to hold an envelope plus one payload byte: no field of it is read, and + // the length word it may carry is not worth reporting as a length. + return FrameCheck(ok = false, reason = FrameRejectReason.BELOW_MINIMUM_LENGTH) + } val length = (frame[1].toInt() and 0xFF) or ((frame[2].toInt() and 0xFF) shl 8) + val total = length + 4 // Ranged CRC checksums the two length bytes in place, with no per-frame allocation. val headerOk = Crc.crc8(frame, 1, 3) == (frame[3].toInt() and 0xFF) var crc32Ok: Boolean? = null - // length must cover at least the envelope's inner bytes (mirrors framing.py). - if (length in 7..(frame.size - 4)) { + // length must cover at least the envelope's inner bytes (mirrors framing.py). Trailing bytes + // do not stop the CRC32 from being computed — that combination (payload CRC right, envelope + // wrong) is precisely the class this change stops admitting, so it must stay observable. + if (length >= 7 && total <= frame.size) { // inner record = frame[4 until length], checksummed in place. val want = Crc.crc32(frame, 4, length) - val got = frame.u32(length) ?: 0L + val got = frame.envU32(length) ?: 0L crc32Ok = want == got } - return FrameCheck(ok = headerOk && (crc32Ok == true), length = length, headerCrcOk = headerOk, crc32Ok = crc32Ok) + val reason = frameRejectReason( + totalFromLength = total, + actualCount = frame.size, + minimumBytes = FrameLimits.WHOOP4_MINIMUM_FRAME_BYTES, + headerCrcOk = headerOk, + crc32Ok = crc32Ok, + ) + return FrameCheck( + ok = reason == FrameRejectReason.NONE, + length = length, + headerCrcOk = headerOk, + crc32Ok = crc32Ok, + reason = reason, + ) } /* @@ -184,9 +355,14 @@ object Framing { * total = declaredLength + 8 (declaredLength counts payload + the 4-byte CRC32 trailer). */ private fun verifyWhoop5(frame: ByteArray): FrameCheck { - if (frame.size < 12 || frame[0] != 0xAA.toByte()) return FrameCheck(ok = false) + if (frame.isEmpty() || frame[0] != 0xAA.toByte()) { + return FrameCheck(ok = false, reason = FrameRejectReason.NO_START_OF_FRAME) + } + // Smallest well-formed whoop5 frame: 8 header bytes (incl. CRC16) + 1 payload byte + 4 CRC32. + if (frame.size < FrameLimits.WHOOP5_MINIMUM_FRAME_BYTES) { + return FrameCheck(ok = false, reason = FrameRejectReason.BELOW_MINIMUM_LENGTH) + } val declaredLength = (frame[2].toInt() and 0xFF) or ((frame[3].toInt() and 0xFF) shl 8) - if (declaredLength < 4) return FrameCheck(ok = false, length = declaredLength) val total = declaredLength + 8 // Ranged CRC over the first 6 header bytes in place, with no copyOfRange. @@ -195,27 +371,65 @@ object Framing { val headerOk = wantHeader == gotHeader var crc32Ok: Boolean? = null - if (frame.size >= total) { + if (declaredLength >= 4 && total <= frame.size) { val payloadEnd = total - 4 // payload = frame[8 until payloadEnd], checksummed in place. val want = Crc.crc32(frame, 8, payloadEnd) - val got = frame.u32(payloadEnd) ?: 0L + val got = frame.envU32(payloadEnd) ?: 0L crc32Ok = want == got } - return FrameCheck(ok = headerOk && (crc32Ok == true), length = declaredLength, headerCrcOk = headerOk, crc32Ok = crc32Ok) + val reason = frameRejectReason( + totalFromLength = total, + actualCount = frame.size, + minimumBytes = FrameLimits.WHOOP5_MINIMUM_FRAME_BYTES, + headerCrcOk = headerOk, + crc32Ok = crc32Ok, + ) + return FrameCheck( + ok = reason == FrameRejectReason.NONE, + length = declaredLength, + headerCrcOk = headerOk, + crc32Ok = crc32Ok, + reason = reason, + ) } /** - * Family-aware envelope + CRC check — true only when the envelope and BOTH CRCs verify. The Kotlin - * twin of Swift's `verifyFrame(_:family:).ok`. Exposed so a decoder outside this object can CRC-gate - * a frame before reading any field (the BLE safety contract's "bad bytes never drive state"); - * [parseFrame] uses the same two private validators internally. + * Family-aware frame validation — the ONE decision point. Kotlin twin of Swift's + * `verifyFrame(_:family:)`: structure (minimum length, then exact length), header checksum, + * then payload CRC32, with a non-null [FrameCheck.reason] saying which rule failed first. */ - fun frameCrcOk(frame: ByteArray, family: DeviceFamily): Boolean = + fun verifyFrame(frame: ByteArray, family: DeviceFamily): FrameCheck = when (family) { - DeviceFamily.WHOOP4 -> verifyWhoop4(frame).ok - DeviceFamily.WHOOP5 -> verifyWhoop5(frame).ok + DeviceFamily.WHOOP4 -> verifyWhoop4(frame) + DeviceFamily.WHOOP5 -> verifyWhoop5(frame) + } + + /** + * Family-aware envelope + CRC check — true only when the envelope, the structural length and BOTH + * CRCs verify. The Kotlin twin of Swift's `verifyFrame(_:family:).ok`. Exposed so a decoder + * outside this object can gate a frame before reading any field (the BLE safety contract's "bad + * bytes never drive state"); [parseFrame] uses the same validator internally. + */ + fun frameCrcOk(frame: ByteArray, family: DeviceFamily): Boolean = verifyFrame(frame, family).ok + + /** + * The D7 bound for reading named inner fields out of [frame] — the minimum of the CRC32 + * trailer's start (derived from the DECLARED length) and the frame's real size. Internal so the + * historical record decoders, which read their fields outside this object, bound them exactly as + * the envelope decoders here do. + */ + internal fun payloadLimit(frame: ByteArray, family: DeviceFamily): Int { + val trailerStart: Int? = when (family) { + // WHOOP 4.0: the CRC32 trailer starts at the declared length. + DeviceFamily.WHOOP4 -> + if (frame.size >= 3) (frame[1].toInt() and 0xFF) or ((frame[2].toInt() and 0xFF) shl 8) else null + // WHOOP 5.0/MG: total = declaredLength + 8, and the trailer is the last 4 of those. + DeviceFamily.WHOOP5 -> + if (frame.size >= 4) ((frame[2].toInt() and 0xFF) or ((frame[3].toInt() and 0xFF) shl 8)) + 4 else null } + return payloadLimitOf(frame, trailerStart) + } // MARK: - type / enum naming @@ -274,32 +488,49 @@ object Framing { } private fun parseWhoop4(frame: ByteArray): ParsedFrame { - if (frame.size < 8 || frame[0] != 0xAA.toByte()) return ParsedFrame.invalid() - val check = verifyWhoop4(frame) + // Below the family minimum there is no inner record at all — every offset a field would use + // lands in the checksum trailer — so nothing is decoded, not even a packet type. + if (frame.size < FrameLimits.WHOOP4_MINIMUM_FRAME_BYTES || frame[0] != 0xAA.toByte()) { + return ParsedFrame.invalid(check.reason) + } + val length = check.length val crcOk = check.crc32Ok + // D7: named inner fields come only from payload bytes. On WHOOP 4.0 the CRC32 trailer starts + // at the declared length. + val limit = payloadLimitOf(frame, length) val t = frame[4].toInt() and 0xFF val name = typeName(t) val parsed = LinkedHashMap() when (name) { - "REALTIME_DATA" -> decodeRealtime(frame, parsed) - "EVENT" -> decodeEvent(frame, length, parsed) - "COMMAND_RESPONSE" -> decodeCommandResponse(frame, length, parsed) - "METADATA" -> decodeMetadata(frame, length, parsed) + "REALTIME_DATA" -> decodeRealtime(frame, limit, parsed) + "EVENT" -> decodeEvent(frame, limit, parsed) + "COMMAND_RESPONSE" -> decodeCommandResponse(frame, limit, parsed) + "METADATA" -> decodeMetadata(frame, limit, parsed) else -> Unit } - return ParsedFrame(ok = true, crcOk = crcOk, typeName = name, parsed = parsed) + // `ok` is the verifier's full verdict, not a constant: the fields above stay decoded so an + // inspector can still read a broken frame, but no consumer may mistake that for integrity. + return ParsedFrame( + ok = check.ok, crcOk = crcOk, typeName = name, parsed = parsed, + rejectReason = check.reason, + ) } private fun parseWhoop5(frame: ByteArray): ParsedFrame { - // Minimum whoop5 frame: 8 header bytes + 1 inner (type) + 4 CRC32 trailer. - if (frame.size < 12 || frame[0] != 0xAA.toByte()) return ParsedFrame.invalid() val check = verifyWhoop5(frame) + // Minimum whoop5 frame: 8 header bytes + 1 payload byte + 4 CRC32 trailer. Below that, the + // type byte at [8] would be the first byte of the frame's own CRC32 trailer. + if (frame.size < FrameLimits.WHOOP5_MINIMUM_FRAME_BYTES || frame[0] != 0xAA.toByte()) { + return ParsedFrame.invalid(check.reason) + } val innerStart = 8 + // D7: bounded by the trailer AND the real size — total = declaredLength + 8, trailer = last 4. + val limit = payloadLimitOf(frame, check.length?.let { it + 4 }) val t = frame[innerStart].toInt() and 0xFF val name = typeName(t) val parsed = LinkedHashMap() @@ -308,14 +539,23 @@ object Framing { // worn frames; see the Swift Whoop5RealtimeTests vector). Other types stay envelope-only until // their per-type 5.0 offsets are confirmed on hardware — we don't invent offsets. when (name) { - "REALTIME_DATA" -> decodeRealtimeWhoop5(frame, parsed) - "METADATA" -> decodeMetadataWhoop5(frame, parsed) - "EVENT" -> decodeEventWhoop5(frame, parsed) - "COMMAND_RESPONSE" -> decodeCommandResponseWhoop5(frame, parsed) - "CONSOLE_LOGS" -> decodeConsoleLogsWhoop5(frame, parsed) + "REALTIME_DATA" -> decodeRealtimeWhoop5(frame, limit, parsed) + "METADATA" -> decodeMetadataWhoop5(frame, limit, parsed) + "EVENT" -> decodeEventWhoop5(frame, limit, parsed) + "COMMAND_RESPONSE" -> decodeCommandResponseWhoop5(frame, limit, parsed) + // WHOOP 5/MG ONLY, and that is a gap rather than a decision. Swift decodes the 4.0 console + // layout too (`PostHooks`, offsets 11..len-1, pinned by a test on real 4.0 text), so after the + // Apple consumer was wired up a WHOOP 4.0 narrates into an iOS strap log and stays silent in an + // Android one — the same defect this fixed on Apple, mirrored onto the other strap. Left for a + // follow-up rather than smuggled in here: it needs the 4.0 offsets and its own vector, and this + // change is already about a key three implementations disagreed on. + "CONSOLE_LOGS" -> decodeConsoleLogsWhoop5(frame, limit, parsed) else -> Unit } - return ParsedFrame(ok = true, crcOk = check.crc32Ok, typeName = name, parsed = parsed) + return ParsedFrame( + ok = check.ok, crcOk = check.crc32Ok, typeName = name, parsed = parsed, + rejectReason = check.reason, + ) } /** @@ -325,19 +565,18 @@ object Framing { * BATTERY_LEVEL the 4.0 payload decode shifts with it: soc%=u16@21/10, mV=u16@25, charging@30 * bit0 (mirrors Swift Interpreter's whoop5 event decode; all gated, fail closed). (#78 fork) */ - private fun decodeEventWhoop5(frame: ByteArray, parsed: MutableMap) { - val evVal = frame.u8(10) ?: return + private fun decodeEventWhoop5(frame: ByteArray, limit: Int, parsed: MutableMap) { + val evVal = frame.u8(10, limit) ?: return parsed["event"] = eventLabel(evVal) - frame.u32(12)?.let { parsed["event_timestamp"] = it.toInt() } - val payEnd = frame.size - 4 - if (payEnd > 16) { - parsed["event_payload_hex"] = frame.copyOfRange(16, payEnd) + frame.u32(12, limit)?.let { parsed["event_timestamp"] = it.toInt() } + if (limit > 16) { + parsed["event_payload_hex"] = frame.copyOfRange(16, limit) .joinToString("") { "%02x".format(it) } } if (EventNumber.fromRaw(evVal) == EventNumber.BATTERY_LEVEL) { - frame.u16(21)?.let { raw -> if (raw <= 1100) parsed["battery_pct"] = raw.toDouble() / 10.0 } - frame.u16(25)?.let { mv -> if (mv in 3000..4300) parsed["battery_mV"] = mv } - frame.u8(30)?.let { ch -> if (ch <= 1) parsed["battery_charging"] = ch and 1 } + frame.u16(21, limit)?.let { raw -> if (raw <= 1100) parsed["battery_pct"] = raw.toDouble() / 10.0 } + frame.u16(25, limit)?.let { mv -> if (mv in 3000..4300) parsed["battery_mV"] = mv } + frame.u8(30, limit)?.let { ch -> if (ch <= 1) parsed["battery_charging"] = ch and 1 } } } @@ -349,20 +588,20 @@ object Framing { * direct percent at @13 (gated ≤100, fail closed; Swift parity — unused until the 5/MG * allowlist grows). */ - private fun decodeCommandResponseWhoop5(frame: ByteArray, parsed: MutableMap) { - val cmd = frame.u8(10) ?: return + private fun decodeCommandResponseWhoop5(frame: ByteArray, limit: Int, parsed: MutableMap) { + val cmd = frame.u8(10, limit) ?: return parsed["resp_cmd"] = commandLabel(cmd) - frame.u8(11)?.let { parsed["resp_seq"] = it } - frame.u8(12)?.let { parsed["result"] = commandResultLabel(it) } + frame.u8(11, limit)?.let { parsed["resp_seq"] = it } + frame.u8(12, limit)?.let { parsed["result"] = commandResultLabel(it) } if (CommandNumber.fromRaw(cmd) == CommandNumber.GET_BATTERY_LEVEL) { - frame.u8(13)?.let { pct -> if (pct <= 100) parsed["battery_pct"] = pct.toDouble() } + frame.u8(13, limit)?.let { pct -> if (pct <= 100) parsed["battery_pct"] = pct.toDouble() } } // GET_HELLO (145): device name + firmware version. Mirrors the Swift Interpreter decode of the // same 50.38.1.0 capture: payload base is frame[11]; the name is printable ASCII at pay[16], // the firmware is 4 bytes at pay[93] gated on pay[93]==50 (the "5.x" generation). The session // token in the same block is deliberately never read. Surfaced on the Devices card. if (cmd == 145) { - val payEnd = frame.size - 4 // drop the trailing CRC32 + val payEnd = limit // payload only: stops where the CRC32 trailer starts (D7) if (payEnd > 11) { val pay = frame.copyOfRange(11, payEnd) val name = StringBuilder() @@ -401,16 +640,19 @@ object Framing { * `decodeWhoop5ConsoleLogs` in Interpreter.swift (its text key is "log"). * (#78 fork, real-frame verified) */ - private fun decodeConsoleLogsWhoop5(frame: ByteArray, parsed: MutableMap) { - frame.u16(9)?.let { parsed["record_index"] = it } - frame.u32(12)?.let { parsed["unix"] = it.toInt() } - frame.u16(16)?.let { parsed["subsec"] = it } - val payEnd = frame.size - 4 + private fun decodeConsoleLogsWhoop5(frame: ByteArray, limit: Int, parsed: MutableMap) { + frame.u16(9, limit)?.let { parsed["record_index"] = it } + frame.u32(12, limit)?.let { parsed["unix"] = it.toInt() } + frame.u16(16, limit)?.let { parsed["subsec"] = it } + val payEnd = limit // payload only: stops where the CRC32 trailer starts (D7) if (payEnd <= 21) return val text = frame.copyOfRange(21, payEnd) .toString(Charsets.UTF_8) .trimEnd('\u0000') - if (text.isNotEmpty()) parsed["console"] = text.take(2048) + // Key "log", not "console": the Python reference decoder golden.json is generated from uses + // "log", and Swift matches it under a parity guard. This side was the odd one out, which is + // how the Apple consumer ported from here read the wrong key and silently found nothing. + if (text.isNotEmpty()) parsed["log"] = text.take(2048) } /** @@ -421,14 +663,14 @@ object Framing { * Backfiller never acked/trimmed → 5/MG offload never completed. Offsets verified against real * WHOOP 5 HISTORY_END frames (Swift decodeWhoop5Metadata, Interpreter.swift:407). (#78) */ - private fun decodeMetadataWhoop5(frame: ByteArray, parsed: MutableMap) { - val mt = frame.u8(10) ?: return + private fun decodeMetadataWhoop5(frame: ByteArray, limit: Int, parsed: MutableMap) { + val mt = frame.u8(10, limit) ?: return parsed["meta_type"] = metaLabel(mt) // Only a HISTORY_END carries unix/subsec/trim; the u-reads null out on the shorter // START/COMPLETE frames, so classifyHistoricalMeta keys those off meta_type alone. - frame.u32(11)?.let { parsed["unix"] = it.toInt() } - frame.u16(15)?.let { parsed["subsec"] = it } - frame.u32(21)?.let { parsed["trim_cursor"] = it.toInt() } + frame.u32(11, limit)?.let { parsed["unix"] = it.toInt() } + frame.u16(15, limit)?.let { parsed["subsec"] = it } + frame.u32(21, limit)?.let { parsed["trim_cursor"] = it.toInt() } } /** @@ -436,15 +678,15 @@ object Framing { * subseconds@14 (u16), heart_rate@16 (u8), rr_count@17, rr@18.. (u16). Mirrors the Swift * parseFrameWhoop5 realtime decode and is covered by the same real-frame test vector. */ - private fun decodeRealtimeWhoop5(frame: ByteArray, parsed: MutableMap) { - frame.u32(10)?.let { parsed["timestamp"] = it.toInt() } - frame.u16(14)?.let { parsed["subseconds"] = it } - frame.u8(16)?.let { parsed["heart_rate"] = it } - val rrn = frame.u8(17) ?: 0 + private fun decodeRealtimeWhoop5(frame: ByteArray, limit: Int, parsed: MutableMap) { + frame.u32(10, limit)?.let { parsed["timestamp"] = it.toInt() } + frame.u16(14, limit)?.let { parsed["subseconds"] = it } + frame.u8(16, limit)?.let { parsed["heart_rate"] = it } + val rrn = frame.u8(17, limit) ?: 0 parsed["rr_count"] = rrn val rrs = ArrayList() for (i in 0 until rrn) { - val v = frame.u16(18 + i * 2) + val v = frame.u16(18 + i * 2, limit) if (v != null && v > 0) rrs.add(v) // drop 0 ms placeholders, matching 4.0 / Swift } parsed["rr_intervals"] = rrs @@ -453,16 +695,16 @@ object Framing { // MARK: - per-type decoders (Whoop 4.0). Ported from PostHooks.swift + the static field specs. /** REALTIME_DATA (type 40): timestamp@6 (u32), heart_rate@12 (u8), rr_count@13, rr@14.. (u16). */ - private fun decodeRealtime(frame: ByteArray, parsed: MutableMap) { - frame.u32(6)?.let { parsed["timestamp"] = it.toInt() } - frame.u16(10)?.let { parsed["subseconds"] = it } - frame.u8(12)?.let { parsed["heart_rate"] = it } - val rrn = frame.u8(13) ?: 0 + private fun decodeRealtime(frame: ByteArray, limit: Int, parsed: MutableMap) { + frame.u32(6, limit)?.let { parsed["timestamp"] = it.toInt() } + frame.u16(10, limit)?.let { parsed["subseconds"] = it } + frame.u8(12, limit)?.let { parsed["heart_rate"] = it } + val rrn = frame.u8(13, limit) ?: 0 parsed["rr_count"] = rrn val rrs = ArrayList() for (i in 0 until rrn) { // Drop 0 ms intervals (placeholders, not beat-to-beat intervals), matching Swift. - val v = frame.u16(14 + i * 2) + val v = frame.u16(14 + i * 2, limit) if (v != null && v > 0) rrs.add(v) } parsed["rr_intervals"] = rrs @@ -472,17 +714,17 @@ object Framing { * EVENT (type 48): event@6 (u8, EventNumber), event_timestamp@8 (u32). * For BATTERY_LEVEL, additionally decode soc@17(/10), mV@21, charging@26 bit0. */ - private fun decodeEvent(frame: ByteArray, length: Int?, parsed: MutableMap) { - val evVal = frame.u8(6) ?: return + private fun decodeEvent(frame: ByteArray, limit: Int, parsed: MutableMap) { + val evVal = frame.u8(6, limit) ?: return parsed["event"] = eventLabel(evVal) - frame.u32(8)?.let { parsed["event_timestamp"] = it.toInt() } + frame.u32(8, limit)?.let { parsed["event_timestamp"] = it.toInt() } - if (EventNumber.fromRaw(evVal) == EventNumber.BATTERY_LEVEL && length != null) { + if (EventNumber.fromRaw(evVal) == EventNumber.BATTERY_LEVEL) { // Fixed layout, empirically verified against captured frames: // soc% = u16@17/10 · mV = u16@21 · charging = u8@26 bit0 - frame.u16(17)?.let { raw -> if (raw <= 1100) parsed["battery_pct"] = raw.toDouble() / 10.0 } - frame.u16(21)?.let { mv -> if (mv in 3000..4300) parsed["battery_mV"] = mv } - frame.u8(26)?.let { ch -> if (ch <= 1) parsed["battery_charging"] = ch and 1 } + frame.u16(17, limit)?.let { raw -> if (raw <= 1100) parsed["battery_pct"] = raw.toDouble() / 10.0 } + frame.u16(21, limit)?.let { mv -> if (mv in 3000..4300) parsed["battery_mV"] = mv } + frame.u8(26, limit)?.let { ch -> if (ch <= 1) parsed["battery_charging"] = ch and 1 } } } @@ -490,12 +732,11 @@ object Framing { * COMMAND_RESPONSE (type 36): resp_cmd@6 (u8, CommandNumber). Decodes the battery level reply. * Payload begins at offset 7; GET_BATTERY_LEVEL stores soc% = u16(payload[2..4]) / 10. */ - private fun decodeCommandResponse(frame: ByteArray, length: Int?, parsed: MutableMap) { - if (length == null) return - val payEnd = minOf(length, frame.size) + private fun decodeCommandResponse(frame: ByteArray, limit: Int, parsed: MutableMap) { + val payEnd = limit // payload only: stops where the CRC32 trailer starts (D7) if (payEnd < 7) return val pay = frame.copyOfRange(7, payEnd) - val cmd = frame.u8(6) ?: return + val cmd = frame.u8(6, limit) ?: return parsed["resp_cmd"] = commandLabel(cmd) // #791: the origin-seq echo and the result code, which the 5/MG path has always exposed (at @11/@12) // and this one never did. Without them a 4.0 strap log cannot say whether a command SUCCEEDED or @@ -546,11 +787,10 @@ object Framing { * METADATA (type 49): meta_type@6 (u8, MetadataType). For a 14-byte payload (') { - val mt = frame.u8(6) ?: return + private fun decodeMetadata(frame: ByteArray, limit: Int, parsed: MutableMap) { + val mt = frame.u8(6, limit) ?: return parsed["meta_type"] = metaLabel(mt) - if (length == null) return - val payEnd = minOf(length, frame.size) + val payEnd = limit // payload only: stops where the CRC32 trailer starts (D7) if (payEnd <= 7) return val pay = frame.copyOfRange(7, payEnd) if (pay.size >= 14) { diff --git a/android/app/src/main/java/com/noop/protocol/HistoricalStreams.kt b/android/app/src/main/java/com/noop/protocol/HistoricalStreams.kt index f72fd7c1de..fe21ff725c 100644 --- a/android/app/src/main/java/com/noop/protocol/HistoricalStreams.kt +++ b/android/app/src/main/java/com/noop/protocol/HistoricalStreams.kt @@ -103,14 +103,16 @@ const val DYN_ACCEL_STILL_THRESHOLD_G: Double = 0.01 // MARK: - little-endian readers (null when out of range; mirror PostHooks.swift u8/u16/u32/f32) -private fun ByteArray.histU8(off: Int): Int? = if (off + 1 <= size) this[off].toInt() and 0xFF else null +private fun ByteArray.histU8(off: Int, limit: Int): Int? = + if (off >= 0 && off + 1 <= limit) this[off].toInt() and 0xFF else null -private fun ByteArray.histU16(off: Int): Int? = - if (off + 2 <= size) (this[off].toInt() and 0xFF) or ((this[off + 1].toInt() and 0xFF) shl 8) else null +private fun ByteArray.histU16(off: Int, limit: Int): Int? = + if (off >= 0 && off + 2 <= limit) (this[off].toInt() and 0xFF) or ((this[off + 1].toInt() and 0xFF) shl 8) + else null /** Signed little-endian i16 (two's complement) -> Int. null when out of range. Mirrors Swift `readI16`. */ -private fun ByteArray.histI16(off: Int): Int? { - if (off + 2 > size) return null +private fun ByteArray.histI16(off: Int, limit: Int): Int? { + if (off < 0 || off + 2 > limit) return null val u = (this[off].toInt() and 0xFF) or ((this[off + 1].toInt() and 0xFF) shl 8) return if (u >= 0x8000) u - 0x10000 else u } @@ -131,8 +133,8 @@ private fun ByteArray.histI16(off: Int): Int? { * Long. Reading it out of a parsed map needs `longOrNull`, NOT `intOrNull` — the latter re-narrows * with `toInt()` and silently undoes this. */ -private fun ByteArray.histU32(off: Int): Long? { - if (off + 4 > size) return null +private fun ByteArray.histU32(off: Int, limit: Int): Long? { + if (off < 0 || off + 4 > limit) return null return (this[off].toLong() and 0xFFL) or ((this[off + 1].toLong() and 0xFFL) shl 8) or ((this[off + 2].toLong() and 0xFFL) shl 16) or @@ -155,8 +157,8 @@ private fun Int?.asU32(): Long? = this?.toLong()?.and(0xFFFF_FFFFL) * then widen to Double. Kotlin's `Float.fromBits(Int)` is the exact analog of * `Float(bitPattern:)`; widening Float -> Double is value-preserving. */ -private fun ByteArray.histF32(off: Int): Double? { - val bits = histU32(off) ?: return null +private fun ByteArray.histF32(off: Int, limit: Int): Double? { + val bits = histU32(off, limit) ?: return null return Float.fromBits(bits.toInt()).toDouble() } @@ -226,15 +228,26 @@ private fun histVersionLayout(version: Int): HistVersion? = when (version) { fun decodeHistorical(frame: ByteArray, family: DeviceFamily = DeviceFamily.WHOOP4): Map? { if (frame.size < 8 || frame[0] != 0xAA.toByte()) return null - // Integrity gate: validate the envelope + CRC32 via the shared Framing parser. We reuse its - // crcOk so a garbled/forged offload frame can never inject rows. parseFrame leaves type-47's - // `parsed` empty (the live decoder skips type-47), so we decode the record ourselves below. + // Integrity gate: the FULL verdict from the shared Framing parser — header checksum, payload + // CRC32 and the structural length together — so a garbled or forged offload frame can never + // inject rows. `ok` alone is now that verdict, so the old extra `crcOk == false` clause would be + // a second, weaker copy of the same question. parseFrame leaves type-47's `parsed` empty (the + // live decoder skips type-47), so we decode the record ourselves below. + // + // Returning null for a rejected frame is ALSO what gets it archived: [rejectedHistoricalRecords] + // reads exactly this outcome, so a frame the tightened verdict now refuses is preserved raw + // before the trim ack rather than dropped (D8). val checked = Framing.parseFrame(frame, family) - if (!checked.ok || checked.crcOk == false) return null + if (!checked.ok) return null + + // D7: named inner fields come only from payload bytes — the record's own CRC32 trailer is never + // decoded as a field. The verdict above already fixed the frame's exact length, so this is the + // trailer start; it is still derived through the one shared helper rather than assumed. + val limit = Framing.payloadLimit(frame, family) // WHOOP 5.0/MG has the longer puffin envelope (record @8), so its v18 layout is decoded separately // at its own absolute offsets (port of Swift decodeWhoop5Historical). WHOOP 4 below is unchanged. - if (family == DeviceFamily.WHOOP5) return decodeWhoop5Historical(frame) + if (family == DeviceFamily.WHOOP5) return decodeWhoop5Historical(frame, limit) if (family != DeviceFamily.WHOOP4) return null if (frame[4].toInt() and 0xFF != PacketType.HISTORICAL_DATA.rawValue) return null @@ -250,9 +263,9 @@ fun decodeHistorical(frame: ByteArray, family: DeviceFamily = DeviceFamily.WHOOP val out = LinkedHashMap() out["hist_version"] = version // Long, not Int — see [histU32]. Pinned by `whoop4_v25_synthetic_unix_high_bit`. - frame.histU32(11)?.let { out["unix"] = it } + frame.histU32(11, limit)?.let { out["unix"] = it } fun grav(off: Int): Double? { - val u = frame.histU16(off) ?: return null + val u = frame.histU16(off, limit) ?: return null return (if (u >= 32768) u - 65536 else u).toDouble() / 16384.0 // i16 LE, ±2 g full-scale } val gx = grav(73); val gy = grav(75); val gz = grav(77) @@ -282,27 +295,27 @@ fun decodeHistorical(frame: ByteArray, family: DeviceFamily = DeviceFamily.WHOOP // unix is the record's REAL unix seconds (no clock offset needed for type-47). // Long, not Int — see [histU32]. Pinned by `whoop4_v24_synthetic_unix_high_bit`. - frame.histU32(layout.unixOff)?.let { out["unix"] = it } - frame.histU8(layout.hrOff)?.let { out["heart_rate"] = it } - val rrn = frame.histU8(layout.rrCountOff) ?: 0 + frame.histU32(layout.unixOff, limit)?.let { out["unix"] = it } + frame.histU8(layout.hrOff, limit)?.let { out["heart_rate"] = it } + val rrn = frame.histU8(layout.rrCountOff, limit) ?: 0 out["rr_count"] = rrn // Up to 4 R-R intervals (u16, ms). Drop 0 ms placeholders, matching PostHooks (`v != 0`). val rrVals = ArrayList() for (i in 0 until minOf(rrn, 4)) { - val v = frame.histU16(layout.rrFirstOff + i * 2) + val v = frame.histU16(layout.rrFirstOff + i * 2, limit) if (v != null && v != 0) rrVals.add(v) } out["rr_intervals"] = rrVals // Full DSP block (V24/V12 only). Each read is guarded; absent fields are simply not emitted. - layout.spo2RedOff?.let { off -> frame.histU16(off)?.let { out["spo2_red"] = it } } - layout.spo2IrOff?.let { off -> frame.histU16(off)?.let { out["spo2_ir"] = it } } - layout.skinTempRawOff?.let { off -> frame.histU16(off)?.let { out["skin_temp_raw"] = it } } - layout.respRateRawOff?.let { off -> frame.histU16(off)?.let { out["resp_rate_raw"] = it } } - layout.gravityXOff?.let { off -> frame.histF32(off)?.let { out["gravity_x"] = it } } - layout.gravityYOff?.let { off -> frame.histF32(off)?.let { out["gravity_y"] = it } } - layout.gravityZOff?.let { off -> frame.histF32(off)?.let { out["gravity_z"] = it } } + layout.spo2RedOff?.let { off -> frame.histU16(off, limit)?.let { out["spo2_red"] = it } } + layout.spo2IrOff?.let { off -> frame.histU16(off, limit)?.let { out["spo2_ir"] = it } } + layout.skinTempRawOff?.let { off -> frame.histU16(off, limit)?.let { out["skin_temp_raw"] = it } } + layout.respRateRawOff?.let { off -> frame.histU16(off, limit)?.let { out["resp_rate_raw"] = it } } + layout.gravityXOff?.let { off -> frame.histF32(off, limit)?.let { out["gravity_x"] = it } } + layout.gravityYOff?.let { off -> frame.histF32(off, limit)?.let { out["gravity_y"] = it } } + layout.gravityZOff?.let { off -> frame.histF32(off, limit)?.let { out["gravity_z"] = it } } // Validate the v24-layout guess for an unmapped version: gravity is the DSP-separated orientation // vector, so |gravity| ≈ 1 g on a real record regardless of motion, and HR is physiological. If the @@ -365,9 +378,9 @@ fun isUnmappedWhoop5HistoricalRecord(frame: ByteArray): Boolean { * v26 (PPG) and other * versions aren't stored, so they return null here (skipped), matching the Swift raw-region treatment. */ -private fun decodeWhoop5Historical(frame: ByteArray): Map? { - if (frame.histU8(8) != PacketType.HISTORICAL_DATA.rawValue) return null - val version = frame.histU8(9) ?: return null +private fun decodeWhoop5Historical(frame: ByteArray, limit: Int): Map? { + if (frame.histU8(8, limit) != PacketType.HISTORICAL_DATA.rawValue) return null + val version = frame.histU8(9, limit) ?: return null // One gate, one list: 18 is the only version this function maps, and v26 (handled by // [decodeWhoop5HistoricalV26] from [extractHistoricalStreams]) is the only other one this platform // maps at all — so [MAPPED_WHOOP5_HISTORICAL_VERSIONS] must contain exactly {18, 26}. That set is @@ -385,23 +398,23 @@ private fun decodeWhoop5Historical(frame: ByteArray): Map? { // decode divergence the per-platform fixture-hex tests cannot see (same bytes, each suite asserting // its own answer). PR #848 hit the same 32-vs-64-bit split in the storage codec. Pinned on both // platforms by the `whoop5_v18_synthetic_record_index_high_bit` fixture in decoder_oracle.json. - frame.histU32(11)?.let { out["record_index"] = it } + frame.histU32(11, limit)?.let { out["record_index"] = it } // Same unsigned-domain rule, and here it is not merely a wrong number: a narrowed `unix` goes // NEGATIVE past 2038-01-19, fails the #547 plausibility floor, and the record is dropped — see // [histU32]. Pinned by `whoop5_v18_synthetic_unix_high_bit`. - frame.histU32(15)?.let { out["unix"] = it } - frame.histU8(22)?.let { out["heart_rate"] = it } - val rrn = frame.histU8(23) ?: 0 + frame.histU32(15, limit)?.let { out["unix"] = it } + frame.histU8(22, limit)?.let { out["heart_rate"] = it } + val rrn = frame.histU8(23, limit) ?: 0 out["rr_count"] = rrn val rrVals = ArrayList() for (i in 0 until minOf(rrn, 4)) { - val v = frame.histU16(24 + i * 2) + val v = frame.histU16(24 + i * 2, limit) if (v != null && v != 0) rrVals.add(v) } out["rr_intervals"] = rrVals // Bytes adjacent to the HR/R-R fields: @36 is a FLAG byte and @37 a duplicate heart rate — not the // two halves of one fixed-point HR; the others are carried raw (meaning not pinned). - frame.histU8(33)?.let { out["cardiac_flags"] = it } + frame.histU8(33, limit)?.let { out["cardiac_flags"] = it } // @36 was read as the low half of a u16 `hr_fixed_8_8` with bpm = value/256. Over 18,650 real v18 // records that model is false. Bit 4 is NEVER set (0/18,650 — a genuine 8.8 fraction sets it ~50% of // the time, and it is the ONLY bit never set); 95.02% of values land in 0x80–0x8F where uniform would @@ -410,48 +423,48 @@ private fun decodeWhoop5Historical(frame: ByteArray): Map? { // this flag byte over 256, so the residual is a flat +0.504 ± 0.189. Bit 7 reads as a VALIDITY bit: // with it clear (n=748) rr_count == 0 in 70.32% of records vs 19.82% with it set, and the @108/@109 // sentinel fires in 69.65% vs 1.32%. The remaining bits are unpinned, so the byte ships raw. - frame.histU8(36)?.let { out["hr_quality_flags"] = it } // bit7 = valid, bit4 never set + frame.histU8(36, limit)?.let { out["hr_quality_flags"] = it } // bit7 = valid, bit4 never set // @37 duplicates heart_rate@22 — equal in 99.575% of records (18,523/18,602), differing only by // -6…+2, and it only tracks HR while @36 bit7 is set (99.74% exact vs 94.12% when clear). - frame.histU8(37)?.let { out["heart_rate_alt"] = it } - frame.histU16(38)?.let { out["rr_packed"] = it } - frame.histU8(40)?.let { out["cardiac_status"] = it } - frame.histF32(45)?.let { out["gravity_x"] = it } - frame.histF32(49)?.let { out["gravity_y"] = it } - frame.histF32(53)?.let { out["gravity_z"] = it } + frame.histU8(37, limit)?.let { out["heart_rate_alt"] = it } + frame.histU16(38, limit)?.let { out["rr_packed"] = it } + frame.histU8(40, limit)?.let { out["cardiac_status"] = it } + frame.histF32(45, limit)?.let { out["gravity_x"] = it } + frame.histF32(49, limit)?.let { out["gravity_y"] = it } + frame.histF32(53, limit)?.let { out["gravity_z"] = it } // Per-second fields beyond HR/gravity, each gated to a physically-real range (cross-validated // worn vs off-wrist). Optical/perfusion @69/71 still doesn't decode consistently and is left raw. - frame.histF32(41)?.let { if (it.isFinite() && it in 0.0..8.0) out["dynamic_acceleration"] = it } - frame.histU16(57)?.let { out["step_motion_counter"] = it } + frame.histF32(41, limit)?.let { if (it.isFinite() && it in 0.0..8.0) out["dynamic_acceleration"] = it } + frame.histU16(57, limit)?.let { out["step_motion_counter"] = it } // @59 a per-step cadence-like byte (never 0; lower when moving faster). Raw — no unit asserted. - frame.histU8(59)?.let { out["step_cadence"] = it } - frame.histU8(63)?.let { if (it in 0..2) out["motion_wear_quality"] = it } + frame.histU8(59, limit)?.let { out["step_cadence"] = it } + frame.histU8(63, limit)?.let { if (it in 0..2) out["motion_wear_quality"] = it } // @63 also reads as a small validated ACTIVITY-CLASS enum (community finding, #316): 0=still, 1=walk, // 2=run, 0xFF=invalid. A lightweight, no-cloud per-record activity readout that rides alongside the // step counter. Only the four known codes are surfaced — anything else (incl. 0xFF) stores nothing so // an unmapped firmware can't inject garbage. - frame.histU8(63)?.let { if (it == 0 || it == 1 || it == 2) out["activity_class"] = it } + frame.histU8(63, limit)?.let { if (it == 0 || it == 1 || it == 2) out["activity_class"] = it } // Auxiliary thermal readings adjacent to the main skin-temperature register, read off a digital // skin-temperature sensor. Carried raw; °C = raw/10. Signed i16, gated to a plausible thermal range // so a wrong offset on an unmapped layout stores nothing rather than garbage. - frame.histI16(69)?.let { if ((it / 10.0) in 0.0..60.0) out["temp_aux_1_raw"] = it } - frame.histI16(71)?.let { if ((it / 10.0) in 0.0..60.0) out["temp_aux_2_raw"] = it } + frame.histI16(69, limit)?.let { if ((it / 10.0) in 0.0..60.0) out["temp_aux_1_raw"] = it } + frame.histI16(71, limit)?.let { if ((it / 10.0) in 0.0..60.0) out["temp_aux_2_raw"] = it } // skin temp: raw u16 (the store keeps it raw, /100 at display). Gate on a plausible thermal range: // °C = raw/100 — gives physiological worn temperatures (median ~34 °C across two straps), whereas a // /128 reading lands at a non-physiological ~27 °C. The gate is only a garbage filter; the absolute // scale lives in the consumer. - frame.histU16(73)?.let { if ((it / 100.0) in 5.0..45.0) out["skin_temp_raw"] = it } + frame.histU16(73, limit)?.let { if ((it / 100.0) in 5.0..45.0) out["skin_temp_raw"] = it } // @75 a 16-bit status word; NOT a deep-sleep marker (low nibble 0 across observed records, equal // awake/asleep — the "80=deep" reading is a misread). - frame.histU16(75)?.let { out["status_word"] = it } + frame.histU16(75, limit)?.let { out["status_word"] = it } // @77 / @79 two further 16-bit status words adjacent to @75; carried raw, meaning not pinned. - frame.histU16(77)?.let { out["status_word_1"] = it } - frame.histU16(79)?.let { out["status_word_2"] = it } + frame.histU16(77, limit)?.let { out["status_word_1"] = it } + frame.histU16(79, limit)?.let { out["status_word_2"] = it } // @81 packs several band flags into one byte. High nibble (bits 4-5) tracks a scored night: // 0 wake / 1 still / 2 asleep / 3 up. bits 2-3 a wake-quality field; bits 0-1 an on-wrist flag. // Deep/REM/light are computed off-band, not here. - frame.histU8(81)?.let { + frame.histU8(81, limit)?.let { out["sleep_state"] = (it shr 4) and 3 out["wake_quality"] = (it shr 2) and 3 out["onwrist"] = it and 3 @@ -474,7 +487,7 @@ private fun decodeWhoop5Historical(frame: ByteArray): Map? { // never back a shipped SpO2 metric, never write spo2Pct/spo2_red/spo2_ir, and never feed a // downstream gate (recovery/illness) until the cross-device contradiction is resolved. Mirror of // Swift Interpreter (@82 block). - frame.histU8(82)?.let { + frame.histU8(82, limit)?.let { out["aux_byte_82"] = it if (it in 70..100) out["spo2_candidate_82"] = it } @@ -493,8 +506,8 @@ private fun decodeWhoop5Historical(frame: ByteArray): Map? { // marker: both bytes read 0 in exactly the 8 records that also carry HR == 0, while 128 occurs // unremarkably while worn (@106 in 10 records, @107 in 103). Magnitudes are device-specific (102–255 / // 119–247 on the R22 strap vs 20–66 / 34–81 on another), so no scale is asserted. - frame.histU8(106)?.let { out["optical_baseline_a"] = it } - frame.histU8(107)?.let { out["optical_baseline_b"] = it } + frame.histU8(106, limit)?.let { out["optical_baseline_a"] = it } + frame.histU8(107, limit)?.let { out["optical_baseline_b"] = it } // @108/@109 a tightly-coupled PAIR (equal ~24% of records, within ±2 ~80%). 128 is a RECORD-level // sentinel, not a per-channel one: amp_a == 128 in 757 records and amp_b == 128 in 757 — the SAME 757, // never one without the other. They do NOT rise with HR; that reading (~34 at HR 40–49 → ~58 at 80–89) @@ -507,10 +520,10 @@ private fun decodeWhoop5Historical(frame: ByteArray): Map? { // and predicts the band's own beat-detection failure (rr_count == 0) at 79.44% vs 19.40% — a 4.09× // lift that SURVIVES holding motion constant (4.11× within dyn_acc < 0.009 g), where shuffled and // circular-shift nulls all sit at ~1.0×. It is the same quality condition @36 bit7 reports. - frame.histU8(108)?.let { out["optical_amp_a"] = it } - frame.histU8(109)?.let { out["optical_amp_b"] = it } + frame.histU8(108, limit)?.let { out["optical_amp_a"] = it } + frame.histU8(109, limit)?.let { out["optical_amp_b"] = it } // @113 a float32 (observed range ~ -5.3..0, 0 = unset); purpose unknown, carried raw. EMPIRICAL. - frame.histF32(113)?.let { if (it.isFinite()) out["unknown_f32_113"] = it } + frame.histF32(113, limit)?.let { if (it.isFinite()) out["unknown_f32_113"] = it } // PROVENANCE / lossless-tail note (A10): this decoder maps only the fields above; bytes past @113 // up to the CRC32 trailer are NOT consumed here and are NOT a per-field loss. The Backfiller hands // the VERBATIM frame (header..tail..CRC) to [RawHistoryArchive] for any record it can't turn into @@ -536,21 +549,21 @@ private fun decodeWhoop5Historical(frame: ByteArray): Map? { */ private data class V26Record(val unix: Long, val samples: List, val burstIndex: Int?) -private fun decodeWhoop5HistoricalV26(frame: ByteArray): V26Record? { - if (frame.histU8(8) != PacketType.HISTORICAL_DATA.rawValue) return null - if (frame.histU8(9) != 26) return null +private fun decodeWhoop5HistoricalV26(frame: ByteArray, limit: Int): V26Record? { + if (frame.histU8(8, limit) != PacketType.HISTORICAL_DATA.rawValue) return null + if (frame.histU8(9, limit) != 26) return null // Long, not Int — see [histU32]. This path was never actually wrong (its one consumer re-widened // with `and 0xFFFFFFFFL`), but carrying the reader's own type removes the mask and the trap. - val unix = frame.histU32(15) ?: return null + val unix = frame.histU32(15, limit) ?: return null val samples = ArrayList(24) var off = 27 while (off < 75) { - val v = frame.histI16(off) ?: break + val v = frame.histI16(off, limit) ?: break samples.add(v) off += 2 } if (samples.isEmpty()) return null - val rawBurstIndex = frame.histU8(21) + val rawBurstIndex = frame.histU8(21, limit) return V26Record(unix = unix, samples = samples, burstIndex = rawBurstIndex?.takeIf { it > 0 }) } @@ -567,8 +580,9 @@ private fun decodeWhoop5HistoricalV26(frame: ByteArray): V26Record? { * - CONSOLE_LOGS (type-50) frames — the strap's own diagnostics text channel. On WHOOP 4.0 the * inner type byte is frame[4]; type-50 (0x32) is not type-47 so the family-aware type guard below * already skips it. On WHOOP 5/MG the inner type byte is at frame[8]. - * - WHOOP 5/MG v26 (raw PPG) records — stored in the dedicated waveform stream, so they do not belong - * in the rejected-record archive. + * - INTACT WHOOP 5/MG v26 (raw PPG) records — stored in the dedicated waveform stream, so they do not + * belong in the rejected-record archive. A REJECTED v26 record banks no waveform either, so it is + * archived like any other rejected record. * - Non-record frames (METADATA, EVENT, etc.) — not type-47, so never returned. * * The Backfiller archives these raw bytes BEFORE acking the trim, so a user on an unmapped firmware @@ -584,10 +598,19 @@ fun rejectedHistoricalRecords( // Inner packet-type byte: WHOOP 5/MG's longer puffin envelope puts it at frame[8]; WHOOP 4 at frame[4]. val typeIndex = if (family == DeviceFamily.WHOOP5) 8 else 4 return rawFrames.filter { frame -> - val t = frame.histU8(typeIndex) ?: return@filter false + // ROUTING reads, bounded by the array itself, NOT by the D7 payload limit: these run on + // frames whose envelope may be broken, where the declared trailer position is exactly what + // cannot be trusted. Bounding them there would make an unreadable frame invisible to the + // archive — the opposite of what this evidence-preserving filter is for (D8). + val t = frame.histU8(typeIndex, frame.size) ?: return@filter false if (t != PacketType.HISTORICAL_DATA.rawValue) return@filter false // type-50 console / metadata / etc. - // WHOOP 5/MG v26 = raw PPG block with its own durable waveform stream, not rejected data. - if (family == DeviceFamily.WHOOP5 && frame.histU8(9) == 26) return@filter false + // WHOOP 5/MG v26 = raw PPG block with its own durable waveform stream — but only while the + // record is INTACT. Since the integrity gate a rejected v26 record banks no waveform either, + // so skipping it on the version byte alone would leave it stored nowhere while its section is + // acked anyway. Bind the skip to the verdict, not to the version. Twin of the Swift clause. + if (family == DeviceFamily.WHOOP5 && frame.histU8(9, frame.size) == 26) { + return@filter !Framing.parseFrame(frame, family).ok + } // UNMAPPED LAYOUT (5/MG) — archive UNCONDITIONALLY, whatever it decoded. // // The decode-outcome test below is the wrong QUESTION for a layout NOOP has no field map for. @@ -649,12 +672,14 @@ sealed class HistoricalMeta { * additionally stores `unix` and `trim_cursor`. We match by prefix so a raw-value change can't * break the classifier. * - * Integrity gate (kept from Swift): only act on a checksum-valid frame — without it a garbled or - * forged BLE peer could forge HISTORY_END / HISTORY_COMPLETE and advance/ack the trim cursor for - * data we never durably stored. + * Integrity gate (kept from Swift): only act on a frame whose FULL verdict is positive — header + * checksum, payload CRC32 and structural length together. Without it a garbled or forged BLE peer + * could forge HISTORY_END / HISTORY_COMPLETE and advance/ack the trim cursor for data we never + * durably stored, and the two classes that used to slip through (a broken header checksum, a frame + * so short its CRC32 could not be computed) are precisely the cheap ones to forge. */ fun classifyHistoricalMeta(p: ParsedFrame): HistoricalMeta { - if (!p.ok || p.crcOk == false) return HistoricalMeta.Other + if (!p.ok) return HistoricalMeta.Other if (p.typeName != "METADATA") return HistoricalMeta.Other val metaName = p.parsed["meta_type"] as? String ?: return HistoricalMeta.Other return when { @@ -808,7 +833,9 @@ fun extractHistoricalStreams( for (frame in rawFrames) { // Packet type byte: WHOOP 5/MG's longer puffin envelope puts it at frame[8]; WHOOP 4 at frame[4]. - val t = if (family == DeviceFamily.WHOOP5) (frame.histU8(8) ?: -1) + // A ROUTING read on a not-yet-verified frame, so it is bounded by the array, not by the D7 + // payload limit (see [rejectedHistoricalRecords]); each branch below applies the full verdict. + val t = if (family == DeviceFamily.WHOOP5) (frame.histU8(8, frame.size) ?: -1) else if (frame.size > 4) frame[4].toInt() and 0xFF else -1 when (t) { PacketType.HISTORICAL_DATA.rawValue -> { @@ -820,8 +847,13 @@ fun extractHistoricalStreams( // (sub-second resolution isn't needed — [PpgHr] indexes by sample position, not ts, and // the emitted HR is per-second). The unix gets the same grossly-stale-RTC correction // (FIX #72) as every other stream. + // ONE verdict for the whole type-47 branch. The v26 waveform below reads the record + // directly instead of going through [decodeHistorical], so without this gate an + // envelope-invalid frame could still bank PPG rows — the Swift twin gates its entire + // extraction loop on the parse verdict, ahead of the per-type branches. + if (!Framing.verifyFrame(frame, family).ok) continue if (family == DeviceFamily.WHOOP5) { - decodeWhoop5HistoricalV26(frame)?.let { rec -> + decodeWhoop5HistoricalV26(frame, Framing.payloadLimit(frame, family))?.let { rec -> // #547: skip a v26 PPG buffer whose unix is implausible (correctedWall → null) so a // bad-clock strap can't seed the derived-HR estimator with garbage-timestamped samples. val baseTs = correctedWall(rec.unix) @@ -980,7 +1012,7 @@ fun extractHistoricalStreams( // Framing decoder doesn't decode type-43 biometrics, so re-parse via parseFrame and // read whatever timestamp/HR/RR it surfaced (typically none on this firmware). val parsed = Framing.parseFrame(frame, family) - if (!parsed.ok || parsed.crcOk == false) continue + if (!parsed.ok) continue val ts = wall(parsed.parsed.intOrNull("timestamp")) ?: continue // #547: gate the wall()-corrected REALTIME_RAW_DATA ts on the same plausibility window — a // bad device clock here would otherwise inject a far-past / future-dated HR/RR row. @@ -999,7 +1031,7 @@ fun extractHistoricalStreams( // wrist/charge/battery events aren't lost. During a backfill the live path is // suppressed, so the offload extractor MUST handle these. val parsed = Framing.parseFrame(frame, family) - if (!parsed.ok || parsed.crcOk == false) continue + if (!parsed.ok) continue val rawTs = parsed.parsed.intOrNull("event_timestamp")?.toLong() ?: continue val kind = (parsed.parsed["event"] as? String) ?: "" // #547: correctedWall now nullable — an EVENT with an implausible event_timestamp is @@ -1022,7 +1054,7 @@ fun extractHistoricalStreams( PacketType.COMMAND_RESPONSE.rawValue -> { // No device timestamp on COMMAND_RESPONSE → stamp battery at wallClockRef (Swift parity). val parsed = Framing.parseFrame(frame, family) - if (!parsed.ok || parsed.crcOk == false) continue + if (!parsed.ok) continue appendHistBattery(battery, wallClockRef.toLong(), parsed.parsed) } @@ -1043,7 +1075,7 @@ fun extractHistoricalStreams( // unknown firmware feature on Android and not on Apple. A CRC failure is a different // finding with its own archive (rejectedHistoricalRecords). val parsed = Framing.parseFrame(frame, family) - if (parsed.ok && parsed.crcOk != false) { + if (parsed.ok) { unhandledTypes[name] = (unhandledTypes[name] ?: 0) + 1 } } diff --git a/android/app/src/main/java/com/noop/protocol/ParsedFrame.kt b/android/app/src/main/java/com/noop/protocol/ParsedFrame.kt index 51d85c8a49..247d78b18e 100644 --- a/android/app/src/main/java/com/noop/protocol/ParsedFrame.kt +++ b/android/app/src/main/java/com/noop/protocol/ParsedFrame.kt @@ -4,10 +4,17 @@ package com.noop.protocol * Result of decoding a single complete frame. * * Mirrors the Swift reference `ParsedFrame` reduced to the fields the Android app consumes: - * - [ok]: the envelope was well-formed (SOF present, minimum length). `false` for fragments. - * - [crcOk]: payload CRC32 outcome — `true`/`false` when verifiable, `null` when not enough bytes - * were present to check (mirrors Swift's optional `crcOK`). A non-`false` value is the integrity - * gate downstream code uses before trusting a frame. + * - [ok]: the FULL integrity verdict of the frame verifier — header checksum, payload CRC32 and the + * structural length together. It means "this frame is intact", NOT "this frame could be parsed": + * a frame with a broken header still carries its decoded [typeName] and [parsed] fields for + * inspection. This is the one gate a consumer has to ask. + * - [crcOk]: payload CRC32 outcome ALONE — `true`/`false` when verifiable, `null` when not enough + * bytes were present to check (mirrors Swift's optional `crcOK`). A diagnostic field: it says + * nothing about the header checksum or the declared length, so it is not an integrity gate. + * - [rejectReason]: why [ok] is false; [FrameRejectReason.NONE] exactly when [ok] is true. Carried + * here so a consumer can report the cause from the value it was handed — the frame is parsed + * exactly once and the result threaded on, so a consumer that had to verify again to learn the + * reason would break that invariant. * - [typeName]: canonical packet-type name (e.g. "REALTIME_DATA", "EVENT", "COMMAND_RESPONSE", * "METADATA"), or "type{N}" / "INVALID/FRAGMENT" when unmapped/invalid. * - [parsed]: a flat map of decoded fields. Values are plain Kotlin types (Int, Double, String, @@ -19,10 +26,22 @@ data class ParsedFrame( val crcOk: Boolean?, val typeName: String, val parsed: Map, + /** + * Defaulted to [FrameRejectReason.NONE] so a hand-built [ParsedFrame] (test fixtures, the + * capture-replay paths that construct one directly) stays valid without naming the new field — + * the Kotlin twin of the Swift decoder tolerating a missing `rejectReason`. + */ + val rejectReason: FrameRejectReason = FrameRejectReason.NONE, ) { companion object { - /** A frame that could not be decoded (too short, wrong SOF, or a mid-stream fragment). */ - fun invalid(): ParsedFrame = - ParsedFrame(ok = false, crcOk = null, typeName = "INVALID/FRAGMENT", parsed = emptyMap()) + /** + * A frame that could not be decoded at all (too short, wrong SOF, or a mid-stream fragment). + * [reason] carries the verifier's verdict so even an undecodable byte run says WHY. + */ + fun invalid(reason: FrameRejectReason): ParsedFrame = + ParsedFrame( + ok = false, crcOk = null, typeName = "INVALID/FRAGMENT", parsed = emptyMap(), + rejectReason = reason, + ) } } diff --git a/android/app/src/main/java/com/noop/protocol/Streams.kt b/android/app/src/main/java/com/noop/protocol/Streams.kt index fda1162025..44406d50b5 100644 --- a/android/app/src/main/java/com/noop/protocol/Streams.kt +++ b/android/app/src/main/java/com/noop/protocol/Streams.kt @@ -1,5 +1,26 @@ package com.noop.protocol +/** Sensor-contact state carried by the standard BLE Heart Rate Measurement flags. */ +enum class StandardHrContact(val storageValue: String) { + UNSUPPORTED("unsupported"), + SUPPORTED_NOT_DETECTED("supported_not_detected"), + SUPPORTED_DETECTED("supported_detected"), + + ; + + companion object { + fun fromMeasurementFlags(flags: Int): StandardHrContact = when { + flags and 0x04 == 0 -> UNSUPPORTED + flags and 0x02 == 0 -> SUPPORTED_NOT_DETECTED + else -> SUPPORTED_DETECTED + } + + fun fromStorageValue(value: String): StandardHrContact? = entries.firstOrNull { + it.storageValue == value + } + } +} + /** * Decoded stream rows — the durable, compact local record produced from parsed frames. * @@ -285,12 +306,14 @@ private fun toWall(deviceTs: Int?, deviceClockRef: Int, wallClockRef: Int): Int? * * HR/R-R are taken ONLY from REALTIME_DATA (type 40). REALTIME_RAW_DATA (type 43) also carries an * HR byte but streams alongside type-40 during raw collection, so routing both would double-count - * HR for the same instants. CRC-failed and non-ok frames are skipped. + * HR for the same instants. Frames whose full integrity verdict is negative are skipped — that is + * the header checksum, the payload CRC32 and the structural length together, not the payload CRC + * alone. */ fun extractStreams(parsed: List, deviceClockRef: Int, wallClockRef: Int): Streams { val out = Streams() for (r in parsed) { - if (!r.ok || r.crcOk == false) continue + if (!r.ok) continue val p = r.parsed when (r.typeName) { "REALTIME_DATA" -> { diff --git a/android/app/src/main/java/com/noop/protocol/Whoop4HelloSerial.kt b/android/app/src/main/java/com/noop/protocol/Whoop4HelloSerial.kt new file mode 100644 index 0000000000..f8220398db --- /dev/null +++ b/android/app/src/main/java/com/noop/protocol/Whoop4HelloSerial.kt @@ -0,0 +1,87 @@ +package com.noop.protocol + +/** + * The WHOOP 4.0 strap serial, read out of the `GET_HELLO_HARVARD` (cmd 35) response (#1193). + * + * A 4.0 does not expose the DIS Serial Number String (`0x2A25`) that gives the 5/MG its stable id, so + * until now the only 4.0 identity available was the transient pairing id the OS mints, which is + * re-minted whenever the strap is re-paired or re-keyed. That is the whole of #1193: one physical strap + * forks into a second registry row and its history is orphaned beside it. + * + * The capture aid added for that hunt found the answer: in a 131-byte 4.0 cmd-35 response the serial is + * the 9-character alphanumeric run at offset 14. + * + * ## What this deliberately does NOT read + * + * The same response carries a 54-character alphanumeric run at offset 24 which is the strap's DEVICE + * KEY. This decoder reads a fixed 9-byte window and cannot reach it: there is no scanning, no + * "longest alnum run", nothing that could drift onto the key as payloads vary. A key must never become + * an id, reach a log, or leave the device, and the cheapest way to guarantee that is to never read it. + * + * ## Why the strict shape check + * + * The offset comes from ONE capture. If a future firmware moves the field, a fixed offset lands on + * whatever is there instead — so a window that is not entirely `[0-9A-Za-z]` is refused rather than + * returned. `WhoopSerialIdentity.adoptedId` then applies its own validation on top. Refusing costs a + * strap the stable id it would have gained; returning something wrong migrates its whole history onto a + * junk key, which is the failure this exists to prevent. + * + * Swift twin: `Whoop4HelloSerial`. + */ +object Whoop4HelloSerial { + + /** Offset of the serial run inside the cmd-35 response payload. */ + const val SERIAL_OFFSET = 14 + + /** Length of the serial run. The 54-char device key begins at offset 24, one byte past this window. */ + const val SERIAL_LENGTH = 9 + + /** The strap serial, or null when the payload is too short or the window is not a clean alnum run. */ + fun decode(payload: ByteArray): String? { + if (payload.size < SERIAL_OFFSET + SERIAL_LENGTH) return null + val sb = StringBuilder(SERIAL_LENGTH) + for (i in SERIAL_OFFSET until SERIAL_OFFSET + SERIAL_LENGTH) { + val b = payload[i].toInt() and 0xFF + val isDigit = b in 0x30..0x39 + val isUpper = b in 0x41..0x5A + val isLower = b in 0x61..0x7A + if (!isDigit && !isUpper && !isLower) return null + sb.append(b.toChar()) + } + return sb.toString() + } +} + +/** + * Withholds a serial until the SAME value has been seen twice (#1193). + * + * The 5/MG adopts its DIS serial on first read, because `0x2A25` is a spec-defined field that means one + * thing. The 4.0 offset is not that: it came off a single capture, so "the 9-char alnum run at offset 14" + * is a strong inference rather than a documented field. If that run were per-session rather than + * per-strap, adopting immediately would mint a new id on every connect and migrate the history each time + * - strictly worse than the duplicate row this exists to prevent. + * + * Two hellos carrying the same run cannot both be a fresh per-session token, which is the only failure + * mode that does real damage. The cost is that a 4.0 adopts one connect later than a 5/MG. + * + * Lives here, apart from the BLE classes that use it, because it is the part most likely to be wrong and + * the part neither platform can unit-test in place. Swift twin: `RepeatedSerialGate`. + */ +class RepeatedSerialGate { + private var candidate: String? = null + private var confirmed: String? = null + + /** + * The serial to adopt, or null while it is still unconfirmed. Returns non-null only on the transition + * to confirmed, so a caller may act on it directly without re-adopting on every later sighting. + */ + fun offer(serial: String): String? { + if (confirmed == serial) return null // already acted on + if (candidate != serial) { + candidate = serial // first sighting: remember, withhold + return null + } + confirmed = serial + return serial + } +} diff --git a/android/app/src/main/java/com/noop/protocol/Whoop5Ecg.kt b/android/app/src/main/java/com/noop/protocol/Whoop5Ecg.kt index f1435a5cf8..a7a4254ce5 100644 --- a/android/app/src/main/java/com/noop/protocol/Whoop5Ecg.kt +++ b/android/app/src/main/java/com/noop/protocol/Whoop5Ecg.kt @@ -166,9 +166,29 @@ object Whoop5Ecg { RIGHT(0, "right"), LEFT(1, "left") } - /** The mainControlECGDataGeneration argument. */ + /** + * The mainControlECGDataGeneration argument. + * + * ⚠️ These raw values are ATTESTED ON ONE DEVICE, and they are NOT the vendor client's declaration + * order. The previous `STOP(0) / START(1) / RESTART(2)` was read off that order and shipped + * unverified (#896). On a WHOOP MG (`WS50_r00`, fw `50.39.1.0`) each argument was sent on its own + * while watching the type-43 stream rather than the ack: + * + * - `0` is REFUSED — the strap answers `FAILURE(0)` and generation is unchanged, so there is no + * case for it here and nothing can send it. + * - `1` STOPS generation. + * - `2` STARTS it. The type-43 stream only follows once `TOGGLE_LABRADOR_FILTERED (139)` is on, so + * the working turn-on order is `139 = 1` then `124 = 2`. 139 gates the STREAM rather than the + * front end: with 139 closed, `124 = 2` still made the strap's own console log + * `MAX86176: Set ECG ON` while no packets arrived (8 sends, 8 console lines, #891). + * + * One device, one firmware. #1100 ran `WS50_r03` / `50.40.1.0` and nothing here says the two agree. + * Whether `2` is a plain start or a stop-then-start is NOT distinguishable from a stream that was + * already off, so the case is named for what it achieves rather than for the client's third name. + * Twin of Swift `ControlSignal`. + */ enum class ControlSignal(val raw: Int, val token: String) { - STOP(0, "stop"), START(1, "start"), RESTART(2, "restart") + STOP(1, "stop"), START(2, "start") } fun commandPayload(arg: Int): List = listOf(COMMAND_REVISION, arg) @@ -229,7 +249,9 @@ object Whoop5Ecg { fun requestsRealtimeData(cmd: Int, arg: Int): Boolean = when (cmd) { TOGGLE_REALTIME_FILTERED_ECG_CMD -> arg != 0 MAIN_CONTROL_ECG_DATA_GENERATION_CMD -> - arg == ControlSignal.START.raw || arg == ControlSignal.RESTART.raw + // Only the START value. `ControlSignal.STOP` (1) halts generation, so a run whose last act + // was that one asked for nothing and its silence is the expected outcome, not a finding. + arg == ControlSignal.START.raw else -> false } @@ -358,6 +380,90 @@ object Whoop5Ecg { fun decodeRawFrame(frame: ByteArray, bytesPerSample: Int, payloadStart: Int = PUFFIN_PAYLOAD_START): RawLabradorPacket? = innerPayload(frame, payloadStart)?.let { decodeRaw(it, bytesPerSample) } + // ---- The type-43 REALTIME_RAW_DATA record: the live ECG sample carrier (#891/#1100) ----------- + // + // OBSERVED on one WHOOP MG (WS50_r00, fw 50.39.1.0), not attested by any vendor document: once + // TOGGLE_LABRADOR_FILTERED(139)=1 has opened the master gate, the strap emits fixed-size 240-byte + // REALTIME_RAW_DATA (type 43) records whose body carries an i16-LE series. The offsets below are the + // OBSERVED layout, and they live here — with the other Labrador protocol facts — rather than in the app + // layer, so the three consumers (live view, signal classifier, waveform export) cannot drift apart and + // so the decode is unit-testable without a strap. + // + // What this layout is NOT: the documented 17-byte status header ([HEADER_LENGTH]) is the FILTERED + // packet's, and it is a separate question whether it also sits inside this record (see #891 §7). Nothing + // here decodes a status field, an HR, or a rhythm classification — only the sample series and a byte-fill + // count. A record that fails CRC must never reach these helpers: callers gate on the frame's CRC first. + + /** Every REALTIME_RAW_DATA record OBSERVED on the MG was exactly this long. */ + const val RAW_RECORD_LENGTH = 240 + + /** Frame offset of the inner record's type byte on 5/MG (`[8]type [9]seq [10]cmd`). */ + const val RAW_TYPE_OFFSET = 8 + + /** First body byte considered by [realtimeRawBodyNonZeroBytes] — excludes the constant sub-header. */ + const val RAW_BODY_START = 24 + + /** First waveform byte. Bytes 24..33 are a constant 5x i16 sub-header that is NOT waveform. */ + const val RAW_WAVEFORM_START = 34 + + /** One past the last body byte; the remaining 4 bytes are the frame's CRC32 trailer. */ + const val RAW_BODY_END = 236 + + /** + * Samples one record carries: 101. Load-bearing beyond arithmetic — a fixed sample count per record is + * exactly the shape that makes autocorrelation manufacture a peak at the record period, so anything + * estimating a rate from these samples must exclude this lag. See the #194 withdrawal. + */ + const val SAMPLES_PER_RAW_RECORD = (RAW_BODY_END - RAW_WAVEFORM_START) / 2 + + /** Non-zero body bytes above which a record is treated as carrying a waveform rather than baseline. */ + const val RAW_BODY_ACTIVE_NONZERO_BYTES = 20 + + /** True for a frame shaped like a REALTIME_RAW_DATA record. Shape only — says nothing about CRC. */ + fun isRealtimeRawRecord(frame: ByteArray): Boolean = + frame.size == RAW_RECORD_LENGTH && + (frame[RAW_TYPE_OFFSET].toInt() and 0xFF) == PacketType.REALTIME_RAW_DATA.rawValue + + /** + * The record's i16-LE sample series, or null when [frame] is not a REALTIME_RAW_DATA record. + * + * Signed two's-complement, little-endian, exactly [SAMPLES_PER_RAW_RECORD] values. Zero samples are + * returned as zeros and never trimmed: for a research artifact a trailing run of zeros is evidence + * about the record, not padding to be tidied away. + */ + fun realtimeRawSamples(frame: ByteArray): IntArray? { + if (!isRealtimeRawRecord(frame)) return null + val out = IntArray(SAMPLES_PER_RAW_RECORD) + var i = RAW_WAVEFORM_START + var n = 0 + while (i + 1 < RAW_BODY_END) { + var v = (frame[i].toInt() and 0xFF) or ((frame[i + 1].toInt() and 0xFF) shl 8) + if (v >= 0x8000) v -= 0x10000 + out[n] = v + n += 1 + i += 2 + } + return out + } + + /** Non-zero bytes in the body region, or null when [frame] is not a REALTIME_RAW_DATA record. */ + fun realtimeRawBodyNonZeroBytes(frame: ByteArray): Int? { + if (!isRealtimeRawRecord(frame)) return null + var n = 0 + for (i in RAW_BODY_START until RAW_BODY_END) if (frame[i].toInt() != 0) n += 1 + return n + } + + /** + * ONE definition of "this record carries a waveform", so the live view, the command-lab signal line and + * the reliability scorer cannot disagree about the same record. Null when not such a record. + * + * What it cannot tell you: a flat record means the electrode circuit is open OR generation is off. It is + * a byte-fill observation, never a statement about the wearer. + */ + fun realtimeRawSignalPresent(frame: ByteArray): Boolean? = + realtimeRawBodyNonZeroBytes(frame)?.let { it > RAW_BODY_ACTIVE_NONZERO_BYTES } + // Discovery /** @@ -394,32 +500,21 @@ object Whoop5Ecg { } /** - * The inner record's payload from a complete 5/MG frame, or null when the frame fails either CRC or - * is too short. Every frame-level entry point goes through here, so no Labrador field is ever read - * out of an unverified frame. + * The inner record's payload from a complete 5/MG frame, or null when the frame fails the envelope + * check. Every frame-level entry point goes through here, so no Labrador field is ever read out of + * an unverified frame. * - * Both CRCs are checked here rather than through `Framing.parseFrame`, whose `crcOk` reports only - * the CRC32 payload check — this needs the same gate as the Swift `verifyFrame(_:family:).ok`, which - * is the CRC16 header check AND the CRC32 payload check. + * The verdict comes from the ONE central verifier ([Framing.verifyFrame]), not from a second + * inline copy of the envelope rules: this path used to re-derive them here with looser bounds + * (`declaredLength >= 4`, no exact-length check), so a truncated frame or one with trailing bytes + * was accepted here while its Swift twin — which has always delegated to the central verifier — + * rejected it. Two field logs disagreeing about the same bytes is exactly what the cross-platform + * contract forbids. */ fun innerPayload(frame: ByteArray, payloadStart: Int = PUFFIN_PAYLOAD_START): List? { - if (frame.size < 12 || frame[0] != 0xAA.toByte()) return null + if (!Framing.frameCrcOk(frame, DeviceFamily.WHOOP5)) return null val declaredLength = (frame[2].toInt() and 0xFF) or ((frame[3].toInt() and 0xFF) shl 8) - if (declaredLength < 4) return null - val total = declaredLength + 8 - if (frame.size < total) return null - - // CRC16-Modbus over the first six header bytes, stored LE at frame[6..8]. - val gotHeader = (frame[6].toInt() and 0xFF) or ((frame[7].toInt() and 0xFF) shl 8) - if (Crc.crc16Modbus(frame, 0, 6) != gotHeader) return null - - val payloadEnd = total - 4 // start of the CRC32 trailer - val gotCrc32 = (frame[payloadEnd].toLong() and 0xFFL) or - ((frame[payloadEnd + 1].toLong() and 0xFFL) shl 8) or - ((frame[payloadEnd + 2].toLong() and 0xFFL) shl 16) or - ((frame[payloadEnd + 3].toLong() and 0xFFL) shl 24) - if (Crc.crc32(frame, 8, payloadEnd) != gotCrc32) return null - + val payloadEnd = declaredLength + 8 - 4 // start of the CRC32 trailer if (payloadStart < 0 || payloadStart >= payloadEnd) return null return (payloadStart until payloadEnd).map { frame[it].toInt() and 0xFF } } diff --git a/android/app/src/main/java/com/noop/protocol/Whoop5RawImu.kt b/android/app/src/main/java/com/noop/protocol/Whoop5RawImu.kt index 16d9b05766..7348577004 100644 --- a/android/app/src/main/java/com/noop/protocol/Whoop5RawImu.kt +++ b/android/app/src/main/java/com/noop/protocol/Whoop5RawImu.kt @@ -9,9 +9,9 @@ package com.noop.protocol // app captures (Asherlc/dofek docs/whoop-ble-protocol.md type-0x2B raw packet) and the same columnar // convention as the 4.0's 1917 REALTIME_RAW_DATA variant. // -// IMPORTANT: the 5/MG's *live* raw-IMU stream is firmware-refused (TOGGLE_IMU_MODE / cmd 106 acks but -// never streams), but the OFFLOAD buffer carries full accel AND gyro — so 100 Hz 6-axis IMU IS -// obtainable on the 5.0 via the historical path. +// Hardware validation on a WHOOP 5/MG established that live raw IMU requires START_RAW_DATA (81) +// followed by TOGGLE_IMU_MODE (106) with payload [1,1]. Opcode 106 on its own acknowledges without +// starting the producer. The same 1244-byte shape is also banked and later returned by history sync. // // Frame layout (reassembled BLE frame = 8-byte puffin envelope + payload; offsets are FRAME-absolute): // @15 u32 LE strap unix seconds for this 1-second frame diff --git a/android/app/src/main/java/com/noop/push/EndpointScopedProgressStore.kt b/android/app/src/main/java/com/noop/push/EndpointScopedProgressStore.kt new file mode 100644 index 0000000000..765a4478a3 --- /dev/null +++ b/android/app/src/main/java/com/noop/push/EndpointScopedProgressStore.kt @@ -0,0 +1,28 @@ +package com.noop.push + +/** Keeps cursor/window progress independent per normalized endpoint without exposing the URL to Room. */ +class EndpointScopedProgressStore( + private val delegate: PushProgressStore, + private val endpointNamespace: String, +) : PushProgressStore { + private fun scoped(deviceId: String) = "$endpointNamespace:$deviceId" + private val prefix = "$endpointNamespace:" + + override suspend fun knownDeviceIds(): Set = delegate.knownDeviceIds() + .filterTo(mutableSetOf()) { it.startsWith(prefix) } + .mapTo(mutableSetOf()) { it.removePrefix(prefix) } + + override suspend fun rememberDeviceId(deviceId: String) = delegate.rememberDeviceId(scoped(deviceId)) + + override suspend fun cursor(table: PushAppendTable, deviceId: String): PushCursor? = + delegate.cursor(table, scoped(deviceId)) + + override suspend fun saveCursor(table: PushAppendTable, deviceId: String, cursor: PushCursor) = + delegate.saveCursor(table, scoped(deviceId), cursor) + + override suspend fun window(table: PushMutableTable, deviceId: String): PushWindowProgress? = + delegate.window(table, scoped(deviceId)) + + override suspend fun saveWindow(table: PushMutableTable, deviceId: String, progress: PushWindowProgress) = + delegate.saveWindow(table, scoped(deviceId), progress) +} diff --git a/android/app/src/main/java/com/noop/push/PushCapabilities.kt b/android/app/src/main/java/com/noop/push/PushCapabilities.kt new file mode 100644 index 0000000000..7494bd30fa --- /dev/null +++ b/android/app/src/main/java/com/noop/push/PushCapabilities.kt @@ -0,0 +1,101 @@ +package com.noop.push + +import org.json.JSONArray +import org.json.JSONObject + +/** A receiver may only narrow the fixed protocol 1.0 registry, never name new data. */ +data class PushCapabilities( + val appendTables: Set, + val mutableTables: Set, + val protocolVersion: String = PushProtocol.VERSION, + val receiverStateId: String = UNSCOPED_RECEIVER_STATE_ID, +) { + val isEmpty: Boolean get() = appendTables.isEmpty() && mutableTables.isEmpty() + val wireNames: List get() = + PushAppendTable.entries.filter { it in appendTables }.map { it.wireName } + + PushMutableTable.entries.filter { it in mutableTables }.map { it.wireName } + + companion object { + /** Test/local default. Network capability documents must always provide a real receiver state id. */ + const val UNSCOPED_RECEIVER_STATE_ID = "00000000-0000-4000-8000-000000000000" + val ALL = PushCapabilities(PushAppendTable.entries.toSet(), PushMutableTable.entries.toSet()) + + fun parse(bytes: ByteArray): PushCapabilities { + if (bytes.size > PushProtocol.MAX_ACK_BYTES) { + throw PushProtocolException("capabilities exceed size limit") + } + val obj = try { + JSONObject(bytes.toString(Charsets.UTF_8)) + } catch (_: Throwable) { + throw PushProtocolException("capabilities are not valid JSON") + } + val required = setOf("type", "protocolVersion", "receiverStateId", "streams") + val actualMembers = obj.keys().asSequence().toSet() + if (!actualMembers.containsAll(required)) { + throw PushProtocolException("capabilities are missing required protocol 1.0 members") + } + if (actualMembers.any { it in PushProtocol.FORBIDDEN_REMOTE_CONTROL_MEMBERS }) { + throw PushProtocolException("capabilities contain forbidden remote-control metadata") + } + if (obj.opt("type") != "capabilities" || obj.opt("protocolVersion") != PushProtocol.VERSION) { + throw PushProtocolException("unsupported capability document") + } + val receiverStateId = (obj.opt("receiverStateId") as? String)?.takeIf(::isCanonicalUuid) + ?: throw PushProtocolException("capabilities.receiverStateId must be a canonical UUID") + val array = obj.opt("streams") as? JSONArray + ?: throw PushProtocolException("capabilities.streams must be an array") + val appendByName = PushAppendTable.entries.associateBy { it.wireName } + val mutableByName = PushMutableTable.entries.associateBy { it.wireName } + val seen = mutableSetOf() + val append = linkedSetOf() + val mutable = linkedSetOf() + for (index in 0 until array.length()) { + val name = array.opt(index) as? String + ?: throw PushProtocolException("capability stream names must be strings") + if (!seen.add(name)) throw PushProtocolException("duplicate capability stream") + when (val table = appendByName[name] ?: mutableByName[name]) { + is PushAppendTable -> append += table + is PushMutableTable -> mutable += table + else -> throw PushProtocolException("unknown capability stream") + } + } + return PushCapabilities(append, mutable, PushProtocol.VERSION, receiverStateId) + } + + private fun isCanonicalUuid(value: String): Boolean = runCatching { + java.util.UUID.fromString(value).toString() == value + }.getOrDefault(false) + } +} + +internal class PushConnectionTester( + private val transportFactory: (PushEndpointPolicy.ValidEndpoint, String) -> PushTransport = + { endpoint, token -> PushHttpTransport(endpoint, token) }, +) { + suspend fun test( + endpoint: PushEndpointPolicy.ValidEndpoint, + token: String, + ): PushCapabilitiesResult = try { + transportFactory(endpoint, token).capabilities() + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (_: Throwable) { + val failure = PushFailure(PushFailureCode.NETWORK_IO) + PushCapabilitiesResult.Rejected(failure.safeCode, failure.retryable, failure) + } +} + +internal fun canStartPushConnectionTest( + networkAvailable: Boolean, + endpointValid: Boolean, + tokenAvailable: Boolean, +): Boolean = networkAvailable && endpointValid && tokenAvailable + +sealed interface PushCapabilitiesResult { + data class Available(val capabilities: PushCapabilities) : PushCapabilitiesResult + data class Rejected( + val reason: String, + val retryable: Boolean, + val failure: PushFailure? = null, + ) : PushCapabilitiesResult +} diff --git a/android/app/src/main/java/com/noop/push/PushCoordinator.kt b/android/app/src/main/java/com/noop/push/PushCoordinator.kt new file mode 100644 index 0000000000..9d7dce4281 --- /dev/null +++ b/android/app/src/main/java/com/noop/push/PushCoordinator.kt @@ -0,0 +1,297 @@ +package com.noop.push + +import kotlinx.coroutines.CancellationException +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** Coordinates bounded Room snapshots and transport without holding a database read across network I/O. */ +class PushCoordinator( + private val source: PushSnapshotSource, + private val transport: PushTransport, + private val progress: PushProgressStore, + private val sourceId: String, + private val today: () -> LocalDate, + private val zoneId: ZoneId, + private val destinationStillCurrent: () -> Boolean = { true }, +) { + suspend fun pushAppend(table: PushAppendTable, deviceId: String): PushResult { + val stored = try { + progress.cursor(table, deviceId) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + return rejected(PushFailure(PushFailureCode.LOCAL_DATABASE)) + } + val effective = if (stored == null || stored.rowId <= 0) { + null + } else { + val atCursor = try { + source.appendRecordAt(table, deviceId, stored.rowId) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (invalid: PushProtocolException) { + return rejected(PushFailure(PushFailureCode.LOCAL_DATA)) + } catch (_: Throwable) { + return rejected(PushFailure(PushFailureCode.LOCAL_DATABASE)) + } + val fingerprint = atCursor?.let { PushProtocol.keyFingerprint(table, deviceId, it.key) } + if (fingerprint == stored.naturalKeyFingerprint) stored else null + } + val rows = try { + source.appendRows(table, deviceId, effective?.rowId ?: 0L, PushProtocol.MAX_RECORDS + 1) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (invalid: PushProtocolException) { + return rejected(PushFailure(PushFailureCode.LOCAL_DATA)) + } catch (_: Throwable) { + return rejected(PushFailure(PushFailureCode.LOCAL_DATABASE)) + } + if (rows.isEmpty()) return PushResult.NoData + val batch = try { + PushProtocol.appendBatch(table, sourceId, deviceId, effective, rows) + } catch (t: PushProtocolException) { + return rejected(PushFailure(PushFailureCode.LOCAL_DATA)) + } + val accepted = deliver(batch) + if (accepted !is PushResult.Accepted) return accepted + val end = batch.endCursor ?: return rejected(PushFailure(PushFailureCode.LOCAL_DATA)) + return try { + progress.saveCursor(table, deviceId, end) + accepted.copy(hasMore = rows.size > batch.recordCount) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (invalid: PushProtocolException) { + return rejected(PushFailure(PushFailureCode.LOCAL_DATA)) + } catch (_: Throwable) { + // The endpoint may have applied the bytes. Keeping the old cursor safely repeats the same upserts. + rejected(PushFailure(PushFailureCode.LOCAL_DATABASE)) + } + } + + suspend fun pushMutable(table: PushMutableTable, deviceId: String): PushResult { + val fullWindow = PushWindow.ending(today(), zoneId) + val rows = try { + source.mutableRows( + table, deviceId, fullWindow, PushProtocol.MAX_MUTABLE_SNAPSHOT_RECORDS + 1, + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (invalid: PushProtocolException) { + return rejected(PushFailure(PushFailureCode.LOCAL_DATA)) + } catch (_: Throwable) { + return rejected(PushFailure(PushFailureCode.LOCAL_DATABASE)) + } + if (rows.size > PushProtocol.MAX_MUTABLE_SNAPSHOT_RECORDS) { + return rejected(PushFailure(PushFailureCode.LOCAL_DATA)) + } + var encodedBytes = 0L + val days = generateSequence(LocalDate.parse(fullWindow.fromDay)) { previous -> + previous.plusDays(1).takeUnless { it.isAfter(LocalDate.parse(fullWindow.toDay)) } + }.toList() + val recordsByDay = days.associateWith { mutableListOf() } + for (record in rows) { + val size = try { + PushProtocol.mutableRecordEncodedSize(table, record) + } catch (t: PushProtocolException) { + return rejected(PushFailure(PushFailureCode.LOCAL_DATA)) + } + encodedBytes += size + if (encodedBytes > PushProtocol.MAX_MUTABLE_SNAPSHOT_ENCODED_BYTES) { + return rejected(PushFailure(PushFailureCode.LOCAL_DATA)) + } + val day = try { + mutableRecordDay(table, record) + } catch (_: PushProtocolException) { + return rejected(PushFailure(PushFailureCode.LOCAL_DATA)) + } + val bucket = recordsByDay[day] + ?: return rejected(PushFailure(PushFailureCode.LOCAL_DATA)) + bucket += record + } + val currentHashes = try { + recordsByDay.mapKeys { (day, _) -> day.toString() } + .mapValues { (_, dayRows) -> PushProtocol.mutableSnapshotHash(table, dayRows) } + } catch (_: PushProtocolException) { + return rejected(PushFailure(PushFailureCode.LOCAL_DATA)) + } + val previousHashes = try { + progress.window(table, deviceId)?.dayHashes.orEmpty() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + return rejected(PushFailure(PushFailureCode.LOCAL_DATABASE)) + } + val changedDays = days.filter { day -> previousHashes[day.toString()] != currentHashes[day.toString()] } + if (changedDays.isEmpty()) return PushResult.NoData + val window = PushWindow.days(changedDays.first(), changedDays.last(), zoneId) + val changedRows = days.asSequence() + .filter { it >= changedDays.first() && it <= changedDays.last() } + .flatMap { recordsByDay.getValue(it).asSequence() } + .toList() + val batches = try { + PushProtocol.mutableBatches(table, sourceId, deviceId, window, changedRows) + } catch (t: PushProtocolException) { + return rejected(PushFailure(PushFailureCode.LOCAL_DATA)) + } + for (batch in batches) { + val accepted = deliver(batch) + if (accepted !is PushResult.Accepted) return accepted + } + val replacementId = batches.first().replacementId ?: batches.first().batchId + return try { + progress.saveWindow( + table, + deviceId, + PushWindowProgress(fullWindow, replacementId, currentHashes), + ) + PushResult.Accepted( + batchId = replacementId, + recordCount = changedRows.size, + hasMore = false, + batchCount = batches.size, + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + rejected(PushFailure(PushFailureCode.LOCAL_DATABASE)) + } + } + + private fun mutableRecordDay(table: PushMutableTable, record: PushMutableRecord): LocalDate = when (table) { + PushMutableTable.DAILY_METRIC, PushMutableTable.JOURNAL -> { + val value = record.key["day"] as? String + ?: throw PushProtocolException("mutable day key is not a string") + runCatching { LocalDate.parse(value) } + .getOrElse { throw PushProtocolException("mutable day key is invalid") } + } + PushMutableTable.SLEEP_SESSION, PushMutableTable.WORKOUT -> { + val value = record.key["startTs"] + val timestamp = when (value) { + is Byte, is Short, is Int, is Long -> (value as Number).toLong() + else -> throw PushProtocolException("mutable startTs key is not an integer") + } + runCatching { Instant.ofEpochSecond(timestamp).atZone(zoneId).toLocalDate() } + .getOrElse { throw PushProtocolException("mutable startTs key is invalid") } + } + } + + /** One append page and one checksum-minimized mutable replacement per actual source device. */ + suspend fun pushKnownDevices( + startDeviceIndex: Int = 0, + maxDevices: Int = Int.MAX_VALUE, + capabilities: PushCapabilities = PushCapabilities.ALL, + ): PushRunResult { + require(startDeviceIndex >= 0) + require(maxDevices > 0) + if (capabilities.isEmpty) { + return PushRunResult( + acceptedBatches = 0, + acceptedRecords = 0, + rejectedBatches = 0, + hasMoreAppendRows = false, + ) + } + val devices = try { + val live = source.knownDeviceIds(capabilities).filter(String::isNotBlank).distinct() + live.forEach { progress.rememberDeviceId(it) } + (live + progress.knownDeviceIds()).filter(String::isNotBlank).distinct().sorted() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + val failure = PushFailure(PushFailureCode.LOCAL_DATABASE) + return PushRunResult(acceptedBatches = 0, acceptedRecords = 0, rejectedBatches = 1, hasMoreAppendRows = false, hasRetryableFailure = true, failure = failure) + } + if (devices.isEmpty()) return PushRunResult(acceptedBatches = 0, acceptedRecords = 0, rejectedBatches = 0, hasMoreAppendRows = false) + val start = startDeviceIndex % devices.size + val selectedCount = minOf(maxDevices, devices.size) + val selectedDevices = (0 until selectedCount).map { devices[(start + it) % devices.size] } + val nextDeviceIndex = (start + selectedCount) % devices.size + var accepted = 0 + var acceptedRecords = 0 + var rejected = 0 + var more = false + var retryableFailure = false + var selectedFailure: PushFailure? = null + for (deviceId in selectedDevices) { + for (table in PushAppendTable.entries.filter { it in capabilities.appendTables }) { + when (val result = pushAppend(table, deviceId)) { + is PushResult.Accepted -> { + accepted += result.batchCount + acceptedRecords += result.recordCount + more = more || result.hasMore + } + is PushResult.Rejected -> { + rejected += 1 + if (selectedFailure == null || result.retryable && !retryableFailure) { + selectedFailure = result.failure + } + retryableFailure = retryableFailure || result.retryable + } + PushResult.NoData -> Unit + } + } + for (table in PushMutableTable.entries.filter { it in capabilities.mutableTables }) { + when (val result = pushMutable(table, deviceId)) { + is PushResult.Accepted -> { + accepted += result.batchCount + acceptedRecords += result.recordCount + } + is PushResult.Rejected -> { + rejected += 1 + if (selectedFailure == null || result.retryable && !retryableFailure) { + selectedFailure = result.failure + } + retryableFailure = retryableFailure || result.retryable + } + PushResult.NoData -> Unit + } + } + } + return PushRunResult( + acceptedBatches = accepted, + acceptedRecords = acceptedRecords, + rejectedBatches = rejected, + hasMoreAppendRows = more, + hasRetryableFailure = retryableFailure, + nextDeviceIndex = nextDeviceIndex, + hasMoreDevices = devices.size > selectedCount, + failure = selectedFailure, + ) + } + + private suspend fun deliver(batch: PushBatch): PushResult { + if (!destinationStillCurrent()) { + throw CancellationException("push destination changed") + } + val response = try { + transport.post(batch) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (transport: PushTransportException) { + return rejected(transport.failure) + } catch (_: Throwable) { + return rejected(PushFailure(PushFailureCode.NETWORK_IO)) + } + if (response.body.size > PushProtocol.MAX_ACK_BYTES) { + return rejected(PushFailure(PushFailureCode.ACK_INVALID)) + } + if (response.statusCode !in 200..299) { + return rejected( + PushFailure.http(response.statusCode, PushError.parseCode(response.body, batch.protocolVersion)), + ) + } + val ack = try { + PushAck.parse(response.body) + } catch (t: PushProtocolException) { + return rejected(PushFailure(PushFailureCode.ACK_INVALID)) + } + if (!ack.exactlyMatches(batch)) { + return rejected(PushFailure(PushFailureCode.ACK_INVALID)) + } + return PushResult.Accepted(batch.batchId, batch.recordCount, hasMore = false) + } + + private fun rejected(failure: PushFailure) = + PushResult.Rejected(failure.safeCode, failure.retryable, failure) +} diff --git a/android/app/src/main/java/com/noop/push/PushDao.kt b/android/app/src/main/java/com/noop/push/PushDao.kt new file mode 100644 index 0000000000..e8c73b1460 --- /dev/null +++ b/android/app/src/main/java/com/noop/push/PushDao.kt @@ -0,0 +1,262 @@ +package com.noop.push + +import android.database.Cursor +import androidx.room.withTransaction +import androidx.sqlite.db.SimpleSQLiteQuery +import com.noop.data.WhoopDatabase + +/** SQL-side upper bound evaluated before Android materializes any unrestricted TEXT value. */ +internal object PushSnapshotPreflight { + // 4 KiB per row conservatively covers maps, entries, boxed scalars and fixed JSON syntax. The + // remaining budget covers text at 6x its UTF-8 byte length, the maximum JSON string expansion. + const val FIXED_ROW_OVERHEAD_BYTES = 4 * 1024L + const val MAX_ESTIMATED_SNAPSHOT_BYTES = 48 * 1024 * 1024L + + fun rowEstimateExpression(columns: List): String = buildString { + append(FIXED_ROW_OVERHEAD_BYTES) + columns.forEach { column -> + append(" + (CASE WHEN $column IS NULL THEN 4 ") + append("WHEN typeof($column) = 'text' THEN length(CAST($column AS BLOB)) * 6 + 2 ") + append("WHEN typeof($column) = 'blob' THEN ${MAX_ESTIMATED_SNAPSHOT_BYTES + 1} ") + append("ELSE 32 END)") + } + } + + fun query(table: String, columns: List, predicate: String, orderBy: String): String = + "SELECT COALESCE(SUM(${rowEstimateExpression(columns)}), 0) FROM " + + "(SELECT ${columns.joinToString()} FROM $table WHERE $predicate ORDER BY $orderBy LIMIT ?)" +} + +internal object PushDeviceDiscovery { + fun query(supportedSqlTables: List): String = buildString { + append("SELECT id AS deviceId FROM device WHERE id <> ''") + supportedSqlTables.distinct().forEach { table -> + append(" UNION SELECT deviceId FROM $table WHERE deviceId <> ''") + } + append(" ORDER BY deviceId") + } +} + +/** + * Narrow, read-only Room snapshot adapter. SQL identifiers come exclusively from the closed enums below; + * user/config input is always a bind argument. Every cursor is consumed and closed inside [withTransaction]. + */ +class PushDao internal constructor(private val db: WhoopDatabase) : PushSnapshotSource { + override suspend fun knownDeviceIds(capabilities: PushCapabilities): List = db.withTransaction { + val supportedTables = capabilities.appendTables.map(::appendSpec) + + capabilities.mutableTables.map(::mutableSpec) + val sql = PushDeviceDiscovery.query(supportedTables.map(TableSpec::sqlName)) + db.query(SimpleSQLiteQuery(sql)).use { cursor -> + buildList { + while (cursor.moveToNext()) cursor.getString(0)?.takeIf { it.isNotBlank() }?.let(::add) + } + } + } + + override suspend fun appendRecordAt( + table: PushAppendTable, + deviceId: String, + rowId: Long, + ): PushAppendRecord? { + val spec = appendSpec(table) + return db.withTransaction { + val args = arrayOf(deviceId, rowId, 1) + ensureSnapshotBounded( + PushSnapshotPreflight.query(spec.sqlName, spec.columns, "deviceId = ? AND rowid = ?", "rowid ASC"), + args, + ) + val sql = "SELECT rowid AS _pushRowId, ${spec.columns.joinToString()} FROM ${spec.sqlName} " + + "WHERE deviceId = ? AND rowid = ? LIMIT 1" + db.query(SimpleSQLiteQuery(sql, arrayOf(deviceId, rowId))).use { cursor -> + if (cursor.moveToFirst()) cursor.appendRecord(spec) else null + } + } + } + + override suspend fun appendRows( + table: PushAppendTable, + deviceId: String, + afterRowId: Long, + limit: Int, + ): List { + require(limit in 1..PushProtocol.MAX_RECORDS + 1) + val spec = appendSpec(table) + return db.withTransaction { + val args = arrayOf(deviceId, afterRowId, limit) + ensureSnapshotBounded( + PushSnapshotPreflight.query(spec.sqlName, spec.columns, "deviceId = ? AND rowid > ?", "rowid ASC"), + args, + ) + val sql = "SELECT rowid AS _pushRowId, ${spec.columns.joinToString()} FROM ${spec.sqlName} " + + "WHERE deviceId = ? AND rowid > ? ORDER BY rowid ASC LIMIT ?" + db.query(SimpleSQLiteQuery(sql, arrayOf(deviceId, afterRowId, limit))).use { cursor -> + buildList { + while (cursor.moveToNext()) add(cursor.appendRecord(spec)) + } + } + } + } + + override suspend fun mutableRows( + table: PushMutableTable, + deviceId: String, + window: PushWindow, + limit: Int, + ): List { + require(limit in 1..(PushProtocol.MAX_MUTABLE_SNAPSHOT_RECORDS + 1)) + val spec = mutableSpec(table) + return db.withTransaction { + val (predicate, bounds) = when (table) { + PushMutableTable.DAILY_METRIC, PushMutableTable.JOURNAL -> + "day >= ? AND day <= ?" to arrayOf(window.fromDay, window.toDay) + PushMutableTable.SLEEP_SESSION, PushMutableTable.WORKOUT -> + "startTs >= ? AND startTs < ?" to + arrayOf(window.startTsInclusive, window.endTsExclusive) + } + val sql = "SELECT ${spec.columns.joinToString()} FROM ${spec.sqlName} " + + "WHERE deviceId = ? AND $predicate ORDER BY ${spec.keyColumns.joinToString()} ASC LIMIT ?" + val args = arrayOfNulls(bounds.size + 2) + args[0] = deviceId + bounds.copyInto(args, destinationOffset = 1) + args[args.lastIndex] = limit + ensureSnapshotBounded( + PushSnapshotPreflight.query( + spec.sqlName, + spec.columns, + "deviceId = ? AND $predicate", + "${spec.keyColumns.joinToString()} ASC", + ), + args, + ) + db.query(SimpleSQLiteQuery(sql, args)).use { cursor -> + buildList { + while (cursor.moveToNext()) add(cursor.mutableRecord(spec)) + } + } + } + } + + private fun ensureSnapshotBounded(sql: String, args: Array) { + val estimate = db.query(SimpleSQLiteQuery(sql, args)).use { cursor -> + if (cursor.moveToFirst()) cursor.getLong(0) else 0L + } + if (estimate > PushSnapshotPreflight.MAX_ESTIMATED_SNAPSHOT_BYTES) { + throw PushProtocolException("snapshot exceeds local memory limit") + } + } + + private fun Cursor.appendRecord(spec: TableSpec): PushAppendRecord { + val values = values(spec) + val rowId = getLong(getColumnIndexOrThrow("_pushRowId")) + return PushAppendRecord( + rowId, + spec.keyColumns.associateWith(values::get), + spec.dataColumns.associateWith(values::get), + ) + } + + private fun Cursor.mutableRecord(spec: TableSpec): PushMutableRecord { + val values = values(spec) + return PushMutableRecord( + spec.keyColumns.associateWith(values::get), + spec.dataColumns.associateWith(values::get), + ) + } + + private fun Cursor.values(spec: TableSpec): Map = buildMap { + for (name in spec.columns) { + val index = getColumnIndexOrThrow(name) + val value: Any? = when (getType(index)) { + Cursor.FIELD_TYPE_NULL -> null + Cursor.FIELD_TYPE_INTEGER -> if (name in spec.booleanColumns) getLong(index) != 0L else getLong(index) + Cursor.FIELD_TYPE_FLOAT -> getDouble(index) + Cursor.FIELD_TYPE_STRING -> getString(index) + else -> throw PushProtocolException("unsupported SQLite type in ${spec.sqlName}.$name") + } + put(name, value) + } + } + + private data class TableSpec( + val sqlName: String, + val keyColumns: List, + val dataColumns: List, + val booleanColumns: Set = emptySet(), + ) { + val columns: List = keyColumns + dataColumns + } + + private fun appendSpec(table: PushAppendTable): TableSpec = when (table) { + PushAppendTable.HR_SAMPLE -> HR + PushAppendTable.RR_INTERVAL -> RR + PushAppendTable.EVENT -> EVENT + PushAppendTable.BATTERY -> BATTERY + PushAppendTable.SPO2_SAMPLE -> SPO2 + PushAppendTable.SKIN_TEMP_SAMPLE -> SKIN_TEMP + PushAppendTable.RESP_SAMPLE -> RESP + PushAppendTable.GRAVITY_SAMPLE -> GRAVITY + } + + private fun mutableSpec(table: PushMutableTable): TableSpec = when (table) { + PushMutableTable.DAILY_METRIC -> DAILY + PushMutableTable.SLEEP_SESSION -> SLEEP + PushMutableTable.WORKOUT -> WORKOUT + PushMutableTable.JOURNAL -> JOURNAL + } + + private companion object { + val HR = TableSpec("hrSample", listOf("ts"), listOf("bpm")) + val RR = TableSpec( + "rrInterval", + listOf("ts", "rrMs", "seq"), + listOf("ord", "srcChannel", "tsSuspect"), + ) + val EVENT = TableSpec( + "event", listOf("ts", "kind"), listOf("payloadJSON"), + ) + val BATTERY = TableSpec( + "battery", listOf("ts"), listOf("soc", "mv", "charging"), + booleanColumns = setOf("charging"), + ) + val SPO2 = TableSpec( + "spo2Sample", listOf("ts"), listOf("red", "ir"), + ) + val SKIN_TEMP = TableSpec( + "skinTempSample", listOf("ts"), listOf("raw", "aux1Raw", "aux2Raw"), + ) + val RESP = TableSpec("respSample", listOf("ts"), listOf("raw")) + val GRAVITY = TableSpec( + "gravitySample", listOf("ts"), listOf("x", "y", "z", "dynAccel"), + ) + val DAILY = TableSpec( + "dailyMetric", + listOf("day"), + listOf( + "totalSleepMin", "efficiency", "deepMin", "remMin", "lightMin", + "disturbances", "restingHr", "avgHrv", "recovery", "strain", "exerciseCount", "spo2Pct", + "skinTempDevC", "respRateBpm", "steps", "activeKcalEst", "spo2Red", "spo2Ir", + ), + ) + val SLEEP = TableSpec( + "sleepSession", + listOf("startTs"), + listOf( + "endTs", "efficiency", "restingHr", "avgHrv", "stagesJSON", + "userEdited", "startTsAdjusted", "motionJSON", "sleepStateJSON", "stagingSparse", + ), + booleanColumns = setOf("userEdited", "stagingSparse"), + ) + val WORKOUT = TableSpec( + "workout", + listOf("startTs", "sport"), + listOf( + "endTs", "source", "durationS", "energyKcal", "avgHr", + "maxHr", "strain", "distanceM", "zonesJSON", "notes", "routePolyline", "steps", + ), + ) + val JOURNAL = TableSpec( + "journal", listOf("day", "question"), listOf("answeredYes", "notes", "numericValue"), + booleanColumns = setOf("answeredYes"), + ) + } +} diff --git a/android/app/src/main/java/com/noop/push/PushEndpointPolicy.kt b/android/app/src/main/java/com/noop/push/PushEndpointPolicy.kt new file mode 100644 index 0000000000..15651b4f67 --- /dev/null +++ b/android/app/src/main/java/com/noop/push/PushEndpointPolicy.kt @@ -0,0 +1,89 @@ +package com.noop.push + +import java.net.IDN +import java.net.Inet4Address +import java.net.Inet6Address +import java.net.InetAddress +import java.net.URI +import java.util.Locale + +/** Security boundary for the user-supplied destination. Validation happens before DNS or HTTP. */ +object PushEndpointPolicy { + data class ValidEndpoint(val url: String, val host: String) + + /** + * Why a destination was refused. A code rather than a sentence: [validate] has no Context, and + * the message is user-facing, so the wording is resolved against string resources at the call + * site the same way [pushFailureMessage] resolves the transport failure taxonomy. + */ + enum class Problem { + MALFORMED_URL, + MISSING_SCHEME, + UNSUPPORTED_SCHEME, + USER_INFO_NOT_ALLOWED, + FRAGMENT_NOT_ALLOWED, + MISSING_HOST, + INVALID_HOST, + INVALID_PORT, + HTTP_REQUIRES_LOCAL_ADDRESS, + } + + sealed interface Result { + data class Valid(val endpoint: ValidEndpoint) : Result + data class Invalid(val problem: Problem) : Result + } + + fun validate(raw: String): Result { + val uri = runCatching { URI(raw.trim()) }.getOrNull() + ?: return Result.Invalid(Problem.MALFORMED_URL) + val scheme = uri.scheme?.lowercase(Locale.ROOT) + ?: return Result.Invalid(Problem.MISSING_SCHEME) + if (scheme != "http" && scheme != "https") return Result.Invalid(Problem.UNSUPPORTED_SCHEME) + if (uri.rawUserInfo != null) return Result.Invalid(Problem.USER_INFO_NOT_ALLOWED) + if (uri.rawFragment != null) return Result.Invalid(Problem.FRAGMENT_NOT_ALLOWED) + val rawHost = uri.host?.removePrefix("[")?.removeSuffix("]")?.lowercase(Locale.ROOT) + ?: return Result.Invalid(Problem.MISSING_HOST) + val asciiHost = if (':' in rawHost) rawHost else runCatching { IDN.toASCII(rawHost) }.getOrNull() + ?: return Result.Invalid(Problem.INVALID_HOST) + if (uri.port !in -1..65535) return Result.Invalid(Problem.INVALID_PORT) + + val literal = parseLiteralAddress(asciiHost) + val literalAllowed = literal?.let(::isLocalAddress) == true + if (scheme == "http" && !literalAllowed) { + return Result.Invalid(Problem.HTTP_REQUIRES_LOCAL_ADDRESS) + } + + val defaultPort = (scheme == "https" && uri.port == 443) || (scheme == "http" && uri.port == 80) + val authorityHost = if (asciiHost.contains(':')) "[$asciiHost]" else asciiHost + val authority = authorityHost + if (uri.port >= 0 && !defaultPort) ":${uri.port}" else "" + val path = uri.rawPath.takeUnless { it.isNullOrEmpty() } ?: "/" + val normalized = buildString { + append(scheme).append("://").append(authority).append(path) + uri.rawQuery?.let { append('?').append(it) } + } + return Result.Valid(ValidEndpoint(normalized, asciiHost)) + } + + internal fun isLocalAddress(address: InetAddress): Boolean { + if (address.isLoopbackAddress || address.isLinkLocalAddress) { + return true + } + if (address is Inet6Address) { + val first = address.address.first().toInt() and 0xff + return first and 0xfe == 0xfc // fc00::/7 ULA + } + if (address is Inet4Address) { + val b = address.address.map { it.toInt() and 0xff } + return b[0] == 10 || (b[0] == 172 && b[1] in 16..31) || + (b[0] == 192 && b[1] == 168) || (b[0] == 169 && b[1] == 254) + } + return false + } + + private fun parseLiteralAddress(host: String): InetAddress? { + val looksV6 = ':' in host + val looksV4 = host.matches(Regex("[0-9.]+")) + if (!looksV4 && !looksV6) return null // Never DNS-resolve while validating user input. + return runCatching { InetAddress.getByName(host) }.getOrNull() + } +} diff --git a/android/app/src/main/java/com/noop/push/PushFailure.kt b/android/app/src/main/java/com/noop/push/PushFailure.kt new file mode 100644 index 0000000000..9e21a1f157 --- /dev/null +++ b/android/app/src/main/java/com/noop/push/PushFailure.kt @@ -0,0 +1,172 @@ +package com.noop.push + +import android.content.Context +import com.noop.R +import java.io.EOFException +import java.io.IOException +import java.io.InterruptedIOException +import java.net.ConnectException +import java.net.NoRouteToHostException +import java.net.SocketException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import java.security.cert.CertificateException +import javax.net.ssl.SSLException +import javax.net.ssl.SSLHandshakeException +import javax.net.ssl.SSLPeerUnverifiedException + +enum class PushFailureCode { + DNS_LOOKUP, + TLS_CERTIFICATE, + TLS_HANDSHAKE, + NETWORK_TIMEOUT, + CONNECTION_REFUSED, + NETWORK_UNREACHABLE, + CONNECTION_RESET, + NETWORK_IO, + HTTP_AUTH, + HTTP_NOT_FOUND, + HTTP_TIMEOUT, + HTTP_TOO_LARGE, + HTTP_MEDIA_TYPE, + HTTP_PROTOCOL_REJECTED, + HTTP_RATE_LIMIT, + HTTP_SERVER, + HTTP_CLIENT, + CAPABILITIES_INVALID, + ACK_INVALID, + LOCAL_DATA, + LOCAL_DATABASE, +} + +data class PushFailure( + val code: PushFailureCode, + val httpStatus: Int? = null, + val receiverCode: String? = null, +) { + val safeCode: String get() = buildString { + append(code.name.lowercase()) + httpStatus?.let { append(":http_").append(it) } + receiverCode?.let { append(":receiver_").append(it) } + } + + val retryable: Boolean get() = when (code) { + PushFailureCode.DNS_LOOKUP, + PushFailureCode.TLS_HANDSHAKE, + PushFailureCode.NETWORK_TIMEOUT, + PushFailureCode.CONNECTION_REFUSED, + PushFailureCode.NETWORK_UNREACHABLE, + PushFailureCode.CONNECTION_RESET, + PushFailureCode.NETWORK_IO, + PushFailureCode.HTTP_TIMEOUT, + PushFailureCode.HTTP_RATE_LIMIT, + PushFailureCode.HTTP_SERVER, + PushFailureCode.LOCAL_DATABASE, + -> true + else -> false + } + + companion object { + fun http(status: Int, receiverCode: String? = null): PushFailure = PushFailure( + code = when (status) { + 401, 403 -> PushFailureCode.HTTP_AUTH + 404 -> PushFailureCode.HTTP_NOT_FOUND + 408 -> PushFailureCode.HTTP_TIMEOUT + 413 -> PushFailureCode.HTTP_TOO_LARGE + 415 -> PushFailureCode.HTTP_MEDIA_TYPE + 400, 409, 422 -> PushFailureCode.HTTP_PROTOCOL_REJECTED + 429 -> PushFailureCode.HTTP_RATE_LIMIT + in 500..599 -> PushFailureCode.HTTP_SERVER + else -> PushFailureCode.HTTP_CLIENT + }, + httpStatus = status, + receiverCode = receiverCode, + ) + } +} + +/** Carries only a stable category; the platform cause is never rendered or persisted. */ +class PushTransportException( + val failure: PushFailure, + cause: Throwable? = null, +) : IOException(failure.safeCode, cause) + +internal fun classifyPushTransportFailure(throwable: Throwable): PushFailure { + val causes = mutableListOf() + var current: Throwable? = throwable + while (current != null && causes.size < 16 && current !in causes) { + causes += current + current = current.cause + } + fun has(type: Class) = causes.any(type::isInstance) + fun messageContains(vararg needles: String) = causes.any { cause -> + val message = cause.message.orEmpty().lowercase() + needles.any(message::contains) + } + val code = when { + has(UnknownHostException::class.java) -> PushFailureCode.DNS_LOOKUP + has(SSLPeerUnverifiedException::class.java) || has(CertificateException::class.java) -> + PushFailureCode.TLS_CERTIFICATE + has(SSLHandshakeException::class.java) || has(SSLException::class.java) -> PushFailureCode.TLS_HANDSHAKE + has(SocketTimeoutException::class.java) || has(InterruptedIOException::class.java) -> + PushFailureCode.NETWORK_TIMEOUT + has(NoRouteToHostException::class.java) || messageContains("no route", "unreachable") -> + PushFailureCode.NETWORK_UNREACHABLE + has(ConnectException::class.java) && messageContains("refused", "econnrefused") -> + PushFailureCode.CONNECTION_REFUSED + has(EOFException::class.java) || + (has(SocketException::class.java) && messageContains("reset", "broken pipe", "unexpected end")) -> + PushFailureCode.CONNECTION_RESET + else -> PushFailureCode.NETWORK_IO + } + return PushFailure(code) +} + +/** + * Wording for a refused destination. Kept beside [pushFailureMessage] so both user-facing message + * taxonomies resolve the same way; [PushEndpointPolicy] itself stays free of Android types. + */ +internal fun pushEndpointProblemMessage( + context: Context, + problem: PushEndpointPolicy.Problem, +): String = when (problem) { + PushEndpointPolicy.Problem.MALFORMED_URL -> context.getString(R.string.push_endpoint_error_malformed) + PushEndpointPolicy.Problem.MISSING_SCHEME -> context.getString(R.string.push_endpoint_error_missing_scheme) + PushEndpointPolicy.Problem.UNSUPPORTED_SCHEME -> + context.getString(R.string.push_endpoint_error_unsupported_scheme) + PushEndpointPolicy.Problem.USER_INFO_NOT_ALLOWED -> context.getString(R.string.push_endpoint_error_user_info) + PushEndpointPolicy.Problem.FRAGMENT_NOT_ALLOWED -> context.getString(R.string.push_endpoint_error_fragment) + PushEndpointPolicy.Problem.MISSING_HOST -> context.getString(R.string.push_endpoint_error_missing_host) + PushEndpointPolicy.Problem.INVALID_HOST -> context.getString(R.string.push_endpoint_error_invalid_host) + PushEndpointPolicy.Problem.INVALID_PORT -> context.getString(R.string.push_endpoint_error_invalid_port) + PushEndpointPolicy.Problem.HTTP_REQUIRES_LOCAL_ADDRESS -> + context.getString(R.string.push_endpoint_error_http_requires_local) +} + +internal fun pushFailureMessage(context: Context, failure: PushFailure): String { + val base = when (failure.code) { + PushFailureCode.DNS_LOOKUP -> context.getString(R.string.push_error_dns) + PushFailureCode.TLS_CERTIFICATE -> context.getString(R.string.push_error_tls_certificate) + PushFailureCode.TLS_HANDSHAKE -> context.getString(R.string.push_error_tls_handshake) + PushFailureCode.NETWORK_TIMEOUT -> context.getString(R.string.push_error_network_timeout) + PushFailureCode.CONNECTION_REFUSED -> context.getString(R.string.push_error_connection_refused) + PushFailureCode.NETWORK_UNREACHABLE -> context.getString(R.string.push_error_network_unreachable) + PushFailureCode.CONNECTION_RESET -> context.getString(R.string.push_error_connection_reset) + PushFailureCode.NETWORK_IO -> context.getString(R.string.push_error_network_io) + PushFailureCode.HTTP_AUTH -> context.getString(R.string.push_error_http_auth, failure.httpStatus ?: 401) + PushFailureCode.HTTP_NOT_FOUND -> context.getString(R.string.push_error_http_not_found, failure.httpStatus ?: 404) + PushFailureCode.HTTP_TIMEOUT -> context.getString(R.string.push_error_http_timeout, failure.httpStatus ?: 408) + PushFailureCode.HTTP_TOO_LARGE -> context.getString(R.string.push_error_http_too_large, failure.httpStatus ?: 413) + PushFailureCode.HTTP_MEDIA_TYPE -> context.getString(R.string.push_error_http_media_type, failure.httpStatus ?: 415) + PushFailureCode.HTTP_PROTOCOL_REJECTED -> + context.getString(R.string.push_error_http_protocol, failure.httpStatus ?: 422) + PushFailureCode.HTTP_RATE_LIMIT -> context.getString(R.string.push_error_http_rate_limit, failure.httpStatus ?: 429) + PushFailureCode.HTTP_SERVER -> context.getString(R.string.push_error_http_server, failure.httpStatus ?: 500) + PushFailureCode.HTTP_CLIENT -> context.getString(R.string.push_error_http_client, failure.httpStatus ?: 400) + PushFailureCode.CAPABILITIES_INVALID -> context.getString(R.string.push_error_capabilities_invalid) + PushFailureCode.ACK_INVALID -> context.getString(R.string.push_error_ack_invalid) + PushFailureCode.LOCAL_DATA -> context.getString(R.string.push_error_local_data) + PushFailureCode.LOCAL_DATABASE -> context.getString(R.string.push_error_local_database) + } + return failure.receiverCode?.let { "$base ($it)" } ?: base +} diff --git a/android/app/src/main/java/com/noop/push/PushHttpTransport.kt b/android/app/src/main/java/com/noop/push/PushHttpTransport.kt new file mode 100644 index 0000000000..9f26262d83 --- /dev/null +++ b/android/app/src/main/java/com/noop/push/PushHttpTransport.kt @@ -0,0 +1,155 @@ +package com.noop.push + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.Call +import okhttp3.Callback +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.RequestBody +import okio.Buffer +import okio.BufferedSink +import okio.GzipSink +import okio.buffer +import java.io.IOException +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** Minimal HTTP adapter: no redirects, no logging, and bounded acknowledgement/error reads. */ +class PushHttpTransport( + private val endpoint: PushEndpointPolicy.ValidEndpoint, + private val bearerToken: String, + private val client: OkHttpClient = defaultClient(), + private val onBatchStart: (PushBatch) -> Unit = {}, +) : PushTransport { + override suspend fun capabilities(): PushCapabilitiesResult { + val request = Request.Builder() + .url(endpoint.url) + .header("Authorization", "Bearer $bearerToken") + .header("Accept", "application/json") + .header(ACCEPT_VERSION_HEADER, PushProtocol.VERSION) + .get() + .build() + val response = try { + executeRequest(request) + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (failure: PushTransportException) { + return PushCapabilitiesResult.Rejected( + failure.failure.safeCode, + failure.failure.retryable, + failure.failure, + ) + } catch (_: Throwable) { + val failure = PushFailure(PushFailureCode.NETWORK_IO) + return PushCapabilitiesResult.Rejected(failure.safeCode, failure.retryable, failure) + } + if (response.statusCode !in 200..299) { + val failure = PushFailure.http(response.statusCode, PushError.parseCode(response.body)) + return PushCapabilitiesResult.Rejected(failure.safeCode, failure.retryable, failure) + } + return try { + PushCapabilitiesResult.Available(PushCapabilities.parse(response.body)) + } catch (invalid: PushProtocolException) { + val failure = PushFailure(PushFailureCode.CAPABILITIES_INVALID) + PushCapabilitiesResult.Rejected(failure.safeCode, failure.retryable, failure) + } + } + + override suspend fun post(batch: PushBatch): PushTransportResponse { + runCatching { onBatchStart(batch) } + val compressedBody = gzip(batch.body) + val compressed = execute(compressedBody, contentEncoding = "gzip") + if (compressed.statusCode != 415) return compressed + // Protocol 1.0 allowed identity requests. A definitive media-type rejection is the only + // signal that permits one compatibility attempt with the exact same decoded entity. + return execute(batch.body, contentEncoding = null) + } + + private suspend fun execute(body: ByteArray, contentEncoding: String?): PushTransportResponse { + val request = Request.Builder() + .url(endpoint.url) + .header("Authorization", "Bearer $bearerToken") + .header("Accept", "application/json") + .apply { if (contentEncoding != null) header("Content-Encoding", contentEncoding) } + .post(FixedRequestBody(body)) + .build() + return executeRequest(request) + } + + private suspend fun executeRequest(request: Request): PushTransportResponse { + try { + return client.newCall(request).await().use { response -> + val bytes = response.body?.byteStream()?.use { input -> + val bounded = ByteArray(PushProtocol.MAX_ACK_BYTES + 1) + var total = 0 + while (total < bounded.size) { + val read = input.read(bounded, total, bounded.size - total) + if (read < 0) break + total += read + } + bounded.copyOf(total) + } ?: ByteArray(0) + PushTransportResponse(response.code, bytes) + } + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (failure: PushTransportException) { + throw failure + } catch (io: IOException) { + throw PushTransportException(classifyPushTransportFailure(io), io) + } + } + + /** Cancelling WorkManager cancels the active socket instead of waiting for the blocking timeout. */ + @OptIn(ExperimentalCoroutinesApi::class) + private suspend fun Call.await(): Response = suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { cancel() } + enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + if (continuation.isActive) continuation.resumeWithException(e) + } + + override fun onResponse(call: Call, response: Response) { + if (continuation.isActive) { + continuation.resume(response) { response.close() } + } else { + response.close() + } + } + }) + } + + companion object { + const val ACCEPT_VERSION_HEADER = "NOOP-Push-Accept-Version" + private val NDJSON = "application/x-ndjson; charset=utf-8".toMediaType() + internal fun defaultClient(): OkHttpClient = OkHttpClient.Builder() + .followRedirects(false) + .followSslRedirects(false) + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .writeTimeout(15, TimeUnit.SECONDS) + .callTimeout(15, TimeUnit.SECONDS) + .build() + + /** Compresses only the already bounded decoded entity and rejects unexpected wire expansion. */ + internal fun gzip(decoded: ByteArray): ByteArray { + require(decoded.size <= PushProtocol.MAX_BODY_BYTES) { "decoded push body exceeds limit" } + val buffer = Buffer() + GzipSink(buffer).buffer().use { it.write(decoded) } + check(buffer.size <= PushProtocol.MAX_WIRE_BODY_BYTES) { "gzip push body exceeds wire limit" } + return buffer.readByteArray() + } + + private class FixedRequestBody(private val bytes: ByteArray) : RequestBody() { + override fun contentType() = NDJSON + override fun contentLength(): Long = bytes.size.toLong() + override fun writeTo(sink: BufferedSink) { + sink.write(bytes) + } + } + } +} diff --git a/android/app/src/main/java/com/noop/push/PushModels.kt b/android/app/src/main/java/com/noop/push/PushModels.kt new file mode 100644 index 0000000000..c42c69dc7e --- /dev/null +++ b/android/app/src/main/java/com/noop/push/PushModels.kt @@ -0,0 +1,189 @@ +package com.noop.push + +import java.time.LocalDate +import java.time.ZoneId + +sealed interface PushTable { + val wireName: String +} + +/** The complete v1 append registry. Database tables are never discovered reflectively. */ +enum class PushAppendTable(override val wireName: String) : PushTable { + HR_SAMPLE("hrSample"), + RR_INTERVAL("rrInterval"), + EVENT("event"), + BATTERY("battery"), + SPO2_SAMPLE("spo2Sample"), + SKIN_TEMP_SAMPLE("skinTempSample"), + RESP_SAMPLE("respSample"), + GRAVITY_SAMPLE("gravitySample"); +} + +enum class PushMutableTable(override val wireName: String) : PushTable { + DAILY_METRIC("dailyMetric"), + SLEEP_SESSION("sleepSession"), + WORKOUT("workout"), + JOURNAL("journal"); +} + +/** Key excludes deviceId (which is batch-scoped); data contains only non-key registry columns. */ +data class PushAppendRecord( + val rowId: Long, + val key: Map, + val data: Map, +) { + init { + require(rowId > 0) { "SQLite rowid must be positive" } + require(key.isNotEmpty()) { "natural key must not be empty" } + } +} + +data class PushMutableRecord( + val key: Map, + val data: Map, +) { + init { + require(key.isNotEmpty()) { "natural key must not be empty" } + } +} + +data class PushWindow( + val fromDay: String, + val toDay: String, + val startTsInclusive: Long, + val endTsExclusive: Long, +) { + companion object { + fun ending(today: LocalDate, zoneId: ZoneId): PushWindow { + val from = today.minusDays(13) + return PushWindow( + fromDay = from.toString(), + toDay = today.toString(), + startTsInclusive = from.atStartOfDay(zoneId).toEpochSecond(), + endTsExclusive = today.plusDays(1).atStartOfDay(zoneId).toEpochSecond(), + ) + } + + fun days(from: LocalDate, to: LocalDate, zoneId: ZoneId): PushWindow { + require(!to.isBefore(from)) + return PushWindow( + fromDay = from.toString(), + toDay = to.toString(), + startTsInclusive = from.atStartOfDay(zoneId).toEpochSecond(), + endTsExclusive = to.plusDays(1).atStartOfDay(zoneId).toEpochSecond(), + ) + } + } +} + +/** Persisted and transmitted cursor. The fingerprint is SHA-256, never raw key material. */ +data class PushCursor(val rowId: Long, val naturalKeyFingerprint: String) + +data class PushWindowProgress( + val window: PushWindow, + val batchId: String, + /** Canonical SHA-256 per local calendar day; absent on pre-checksum installations. */ + val dayHashes: Map = emptyMap(), +) + +/** Fully materialized bounded request; no Room transaction survives into transport. */ +data class PushBatch( + val protocolVersion: String, + val batchId: String, + val sourceId: String, + val table: PushTable, + val deviceId: String, + val mode: String, + val startCursor: PushCursor?, + val endCursor: PushCursor?, + val recordCount: Int, + val window: PushWindow?, + val replacementId: String? = null, + val part: Int? = null, + val parts: Int? = null, + val body: ByteArray, +) + +data class PushTransportResponse(val statusCode: Int, val body: ByteArray) + +/** Bounded, machine-readable receiver diagnostic; arbitrary response text is never retained. */ +data class PushError(val protocolVersion: String, val code: String) { + companion object { + private val SAFE_CODE = Regex("[a-z][a-z0-9_]{0,63}") + + fun parseCode(bytes: ByteArray, expectedVersion: String = PushProtocol.VERSION): String? { + if (bytes.isEmpty() || bytes.size > PushProtocol.MAX_ACK_BYTES) return null + return runCatching { + val obj = org.json.JSONObject(bytes.toString(Charsets.UTF_8)) + val code = obj.opt("code") as? String + if (obj.opt("type") == "error" && obj.opt("protocolVersion") == expectedVersion && + code != null && code.matches(SAFE_CODE) + ) code else null + }.getOrNull() + } + } +} + +interface PushTransport { + suspend fun capabilities(): PushCapabilitiesResult = + PushCapabilitiesResult.Available(PushCapabilities.ALL) + suspend fun post(batch: PushBatch): PushTransportResponse +} + +interface PushProgressStore { + suspend fun knownDeviceIds(): Set + suspend fun rememberDeviceId(deviceId: String) + suspend fun cursor(table: PushAppendTable, deviceId: String): PushCursor? + suspend fun saveCursor(table: PushAppendTable, deviceId: String, cursor: PushCursor) + suspend fun window(table: PushMutableTable, deviceId: String): PushWindowProgress? + suspend fun saveWindow(table: PushMutableTable, deviceId: String, progress: PushWindowProgress) +} + +/** All methods return bounded snapshots and close their database transaction before returning. */ +interface PushSnapshotSource { + suspend fun knownDeviceIds(capabilities: PushCapabilities = PushCapabilities.ALL): List + + suspend fun appendRecordAt(table: PushAppendTable, deviceId: String, rowId: Long): PushAppendRecord? + + suspend fun appendRows( + table: PushAppendTable, + deviceId: String, + afterRowId: Long, + limit: Int, + ): List + + suspend fun mutableRows( + table: PushMutableTable, + deviceId: String, + window: PushWindow, + limit: Int, + ): List +} + +sealed interface PushResult { + data class Accepted( + val batchId: String, + val recordCount: Int, + val hasMore: Boolean, + val batchCount: Int = 1, + ) : PushResult + + data object NoData : PushResult + + data class Rejected( + val reason: String, + val retryable: Boolean, + val failure: PushFailure? = null, + ) : PushResult +} + +data class PushRunResult( + val acceptedBatches: Int, + val rejectedBatches: Int, + val hasMoreAppendRows: Boolean, + val acceptedRecords: Int = 0, + val hasRetryableFailure: Boolean = false, + val nextDeviceIndex: Int = 0, + val hasMoreDevices: Boolean = false, + val failure: PushFailure? = null, +) diff --git a/android/app/src/main/java/com/noop/push/PushProtocol.kt b/android/app/src/main/java/com/noop/push/PushProtocol.kt new file mode 100644 index 0000000000..212d415b9f --- /dev/null +++ b/android/app/src/main/java/com/noop/push/PushProtocol.kt @@ -0,0 +1,542 @@ +package com.noop.push + +import org.json.JSONObject +import java.security.MessageDigest +import java.util.UUID + +class PushProtocolException(message: String) : IllegalArgumentException(message) + +/** Deterministic, bounded NDJSON encoder and acknowledgement codec for protocol 1.0. */ +object PushProtocol { + const val VERSION = "1.0" + const val MAX_RECORDS = 5_000 + /** Hard limit for the decoded UTF-8 NDJSON entity, before optional content coding. */ + const val MAX_BODY_BYTES = 4 * 1024 * 1024 + /** Deflate framing can be slightly larger than incompressible input; keep that copy bounded too. */ + const val MAX_WIRE_BODY_BYTES = MAX_BODY_BYTES + 64 * 1024 + const val MAX_ACK_BYTES = 16 * 1024 + internal const val SNAPSHOT_PAGE_SIZE = 5_000 + // A rolling window may be multipart, but client memory use is fail-closed and independent of DB size. + internal const val MAX_MUTABLE_SNAPSHOT_RECORDS = 1_000 + internal const val MAX_MUTABLE_SNAPSHOT_ENCODED_BYTES = 2 * 1024 * 1024 + internal val FORBIDDEN_REMOTE_CONTROL_MEMBERS = setOf( + "command", "commands", "endpoint", "url", "cadence", "schema", "fields", + ) + + fun appendBatch( + table: PushAppendTable, + sourceId: String, + deviceId: String, + startCursor: PushCursor?, + records: List, + ): PushBatch { + validateUuid(sourceId, "sourceId") + if (records.isEmpty()) throw PushProtocolException("append batch must contain a record") + require(records.zipWithNext().all { (a, b) -> a.rowId < b.rowId }) { + "append records must be strictly ordered by rowid" + } + records.forEach { validateRecord(table, it.key, it.data) } + val candidates = records.take(MAX_RECORDS) + val selectedRows = ArrayList(candidates.size) + val selectedLines = ArrayList(candidates.size) + var rowBytes = 0 + for (i in candidates.indices) { + val candidate = candidates[i] + // Encode one row at a time so rows beyond the decoded entity bound never create a + // second page-sized collection of byte arrays in memory. + val encodedRow = encodeRecordLine(candidate) + val end = cursorFor(table, deviceId, candidate) + val candidateCount = selectedRows.size + 1 + val headerSize = appendHeader( + sourceId, table, deviceId, startCursor, end, candidateCount, UUID_PLACEHOLDER, + ).size + if (headerSize + rowBytes + encodedRow.size > MAX_BODY_BYTES) break + selectedRows += candidate + selectedLines += encodedRow + rowBytes += encodedRow.size + } + if (selectedRows.isEmpty()) throw PushProtocolException("first append record exceeds the 4 MiB decoded batch limit") + + val endCursor = cursorFor(table, deviceId, selectedRows.last()) + val identity = appendIdentity(sourceId, table, deviceId, startCursor, endCursor, selectedRows.size) + val batchId = stableUuid(identity, selectedLines) + val header = appendHeader(sourceId, table, deviceId, startCursor, endCursor, selectedRows.size, batchId) + val body = concatenate(header, selectedLines) + check(body.size <= MAX_BODY_BYTES) + return PushBatch( + protocolVersion = VERSION, + batchId = batchId, + sourceId = sourceId, + table = table, + deviceId = deviceId, + mode = "append", + startCursor = startCursor, + endCursor = endCursor, + recordCount = selectedRows.size, + window = null, + body = body, + ) + } + + /** Builds every bounded part of one authoritative replacement. Empty snapshots produce one part. */ + fun mutableBatches( + table: PushMutableTable, + sourceId: String, + deviceId: String, + window: PushWindow, + records: List, + ): List { + validateUuid(sourceId, "sourceId") + records.forEach { validateRecord(table, it.key, it.data) } + val duplicate = records.groupingBy { orderedObjectJson(it.key) }.eachCount().any { it.value > 1 } + if (duplicate) throw PushProtocolException("replace_window contains a duplicate key") + val lines = records.map(::encodeRecordLine) + val replacementIdentity = mapOf( + "deviceId" to deviceId, + "delivery" to "replace_window", + "protocolVersion" to VERSION, + "sourceId" to sourceId, + "stream" to table.wireName, + "window" to selectorBounds(table, window), + ) + val replacementId = stableUuid(replacementIdentity, lines) + + val chunks = mutableListOf>() + var current = mutableListOf() + var currentBytes = 0 + for (line in lines) { + val nextCount = current.size + 1 + val conservativeHeader = mutableHeader( + sourceId = sourceId, + table = table, + deviceId = deviceId, + window = window, + replacementId = replacementId, + part = Int.MAX_VALUE, + parts = Int.MAX_VALUE, + count = nextCount, + batchId = UUID_PLACEHOLDER, + ) + if (nextCount > MAX_RECORDS || conservativeHeader.size + currentBytes + line.size > MAX_BODY_BYTES) { + if (current.isEmpty()) throw PushProtocolException("first replace_window record exceeds the 4 MiB decoded batch limit") + chunks += current + current = mutableListOf() + currentBytes = 0 + } + val oneHeader = mutableHeader( + sourceId, table, deviceId, window, replacementId, + Int.MAX_VALUE, Int.MAX_VALUE, 1, UUID_PLACEHOLDER, + ) + if (oneHeader.size + line.size > MAX_BODY_BYTES) { + throw PushProtocolException("replace_window record exceeds the 4 MiB decoded batch limit") + } + current += line + currentBytes += line.size + } + if (current.isNotEmpty() || chunks.isEmpty()) chunks += current + + val parts = chunks.size + return chunks.mapIndexed { index, partLines -> + val part = index + 1 + val identity = mutableIdentity( + sourceId, table, deviceId, window, replacementId, part, parts, partLines.size, + ) + val batchId = stableUuid(identity, partLines) + val header = mutableHeader( + sourceId, table, deviceId, window, replacementId, part, parts, partLines.size, batchId, + ) + val body = concatenate(header, partLines) + check(partLines.size <= MAX_RECORDS && body.size <= MAX_BODY_BYTES) + PushBatch( + protocolVersion = VERSION, + batchId = batchId, + sourceId = sourceId, + table = table, + deviceId = deviceId, + mode = "replace_window", + startCursor = null, + endCursor = null, + recordCount = partLines.size, + window = window, + replacementId = replacementId, + part = part, + parts = parts, + body = body, + ) + } + } + + fun mutableBatch( + table: PushMutableTable, + sourceId: String, + deviceId: String, + window: PushWindow, + records: List, + ): PushBatch = mutableBatches(table, sourceId, deviceId, window, records).singleOrNull() + ?: throw PushProtocolException("replace_window requires multiple parts") + + /** SHA-256(stream LF device LF compact-natural-key), matching cursor invalidation contract. */ + fun keyFingerprint(table: PushAppendTable, deviceId: String, key: Map): String { + validateRecordKeys(table, key) + return sha256Hex("${table.wireName}\n$deviceId\n${orderedObjectJson(key)}".toByteArray(Charsets.UTF_8)) + } + + internal fun mutableRecordEncodedSize(table: PushMutableTable, record: PushMutableRecord): Int { + validateRecord(table, record.key, record.data) + return encodeRecordLine(record).size + } + + /** Stable local content identity. It is progress metadata and is never sent to the receiver. */ + internal fun mutableSnapshotHash( + table: PushMutableTable, + records: List, + ): String { + val lines = records.map { record -> + validateRecord(table, record.key, record.data) + encodeRecordLine(record) + }.sortedWith { left, right -> compareBytes(left, right) } + val digest = MessageDigest.getInstance("SHA-256") + digest.update("noop-push-day-hash\n$VERSION\n${table.wireName}\n".toByteArray(Charsets.UTF_8)) + lines.forEach(digest::update) + return digest.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) } + } + + internal fun canonicalJson(value: Any?): String = buildString { appendCanonical(value, sortMaps = true) } + + private fun orderedObjectJson(value: Map): String = buildString { + appendCanonical(value, sortMaps = false) + } + + private fun StringBuilder.appendCanonical(value: Any?, sortMaps: Boolean) { + when (value) { + null -> append("null") + is String -> appendQuoted(value) + is Boolean -> append(if (value) "true" else "false") + is Byte, is Short, is Int, is Long -> append((value as Number).toLong()) + is Float, is Double -> { + val d = (value as Number).toDouble() + if (!d.isFinite()) throw PushProtocolException("non-finite number is not valid JSON") + append(java.lang.Double.toString(d)) + } + is Map<*, *> -> { + val entries = value.entries.map { + val key = it.key as? String ?: throw PushProtocolException("JSON object key must be a string") + key to it.value + }.let { if (sortMaps) it.sortedBy { entry -> entry.first } else it } + append('{') + entries.forEachIndexed { index, (key, item) -> + if (index > 0) append(',') + appendQuoted(key) + append(':') + appendCanonical(item, sortMaps) + } + append('}') + } + is Iterable<*> -> { + append('[') + value.forEachIndexed { index, item -> + if (index > 0) append(',') + appendCanonical(item, sortMaps) + } + append(']') + } + else -> throw PushProtocolException("unsupported JSON value ${value::class.java.name}") + } + } + + private fun StringBuilder.appendQuoted(value: String) { + append('"') + for (ch in value) { + when (ch) { + '"' -> append("\\\"") + '\\' -> append("\\\\") + '\b' -> append("\\b") + '\u000C' -> append("\\f") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> if (ch.code < 0x20) append("\\u%04x".format(ch.code)) else append(ch) + } + } + append('"') + } + + private fun encodeRecordLine(record: PushAppendRecord): ByteArray = + encodeLine(mapOf("data" to record.data, "key" to record.key, "type" to "record")) + + private fun encodeRecordLine(record: PushMutableRecord): ByteArray = + encodeLine(mapOf("data" to record.data, "key" to record.key, "type" to "record")) + + private fun encodeLine(value: Map): ByteArray = + (canonicalJson(value) + "\n").toByteArray(Charsets.UTF_8) + + private fun appendIdentity( + sourceId: String, + table: PushAppendTable, + deviceId: String, + start: PushCursor?, + end: PushCursor, + count: Int, + ): Map = mapOf( + "delivery" to "append", + "deviceId" to deviceId, + "endCursor" to cursorJson(end), + "protocolVersion" to VERSION, + "recordCount" to count, + "sourceId" to sourceId, + "startCursor" to start?.let(::cursorJson), + "stream" to table.wireName, + "type" to "batch", + ) + + private fun appendHeader( + sourceId: String, + table: PushAppendTable, + deviceId: String, + start: PushCursor?, + end: PushCursor, + count: Int, + batchId: String, + ): ByteArray = encodeLine(appendIdentity(sourceId, table, deviceId, start, end, count) + ("batchId" to batchId)) + + private fun mutableIdentity( + sourceId: String, + table: PushMutableTable, + deviceId: String, + window: PushWindow, + replacementId: String, + part: Int, + parts: Int, + count: Int, + ): Map = mapOf( + "delivery" to "replace_window", + "deviceId" to deviceId, + "endCursor" to null, + "protocolVersion" to VERSION, + "recordCount" to count, + "sourceId" to sourceId, + "startCursor" to null, + "stream" to table.wireName, + "type" to "batch", + "window" to (selectorBounds(table, window) + mapOf( + "part" to part, + "parts" to parts, + "replacementId" to replacementId, + )), + ) + + private fun mutableHeader( + sourceId: String, + table: PushMutableTable, + deviceId: String, + window: PushWindow, + replacementId: String, + part: Int, + parts: Int, + count: Int, + batchId: String, + ): ByteArray = encodeLine( + mutableIdentity(sourceId, table, deviceId, window, replacementId, part, parts, count) + + ("batchId" to batchId), + ) + + private fun selectorBounds(table: PushMutableTable, window: PushWindow): Map = when (table) { + PushMutableTable.DAILY_METRIC, PushMutableTable.JOURNAL -> mapOf( + "endExclusive" to java.time.LocalDate.parse(window.toDay).plusDays(1).toString(), + "selector" to "day", + "startInclusive" to window.fromDay, + ) + PushMutableTable.SLEEP_SESSION, PushMutableTable.WORKOUT -> mapOf( + "endExclusive" to window.endTsExclusive, + "selector" to "startTs", + "startInclusive" to window.startTsInclusive, + ) + } + + private fun cursorFor(table: PushAppendTable, deviceId: String, record: PushAppendRecord) = + PushCursor(record.rowId, keyFingerprint(table, deviceId, record.key)) + + private fun cursorJson(cursor: PushCursor): Map = mapOf( + "keySha256" to cursor.naturalKeyFingerprint, + "rowId" to cursor.rowId, + ) + + private fun stableUuid(header: Map, lines: List): String { + val digest = MessageDigest.getInstance("SHA-256") + digest.update(canonicalJson(header).toByteArray(Charsets.UTF_8)) + digest.update('\n'.code.toByte()) + lines.forEach(digest::update) + val bytes = digest.digest().copyOf(16) + bytes[6] = ((bytes[6].toInt() and 0x0f) or 0x50).toByte() + bytes[8] = ((bytes[8].toInt() and 0x3f) or 0x80).toByte() + val buffer = java.nio.ByteBuffer.wrap(bytes) + return UUID(buffer.long, buffer.long).toString() + } + + private fun concatenate(header: ByteArray, lines: List): ByteArray { + val out = ByteArray(header.size + lines.sumOf { it.size }) + var offset = 0 + header.copyInto(out, offset) + offset += header.size + lines.forEach { line -> + line.copyInto(out, offset) + offset += line.size + } + return out + } + + private fun compareBytes(left: ByteArray, right: ByteArray): Int { + val common = minOf(left.size, right.size) + for (index in 0 until common) { + val difference = (left[index].toInt() and 0xff) - (right[index].toInt() and 0xff) + if (difference != 0) return difference + } + return left.size - right.size + } + + private fun validateRecord(table: PushTable, key: Map, data: Map) { + val spec = REGISTRY.getValue(table.wireName) + if (key.keys.toList() != spec.first) throw PushProtocolException("${table.wireName} key does not match registry") + if (data.keys.toSet() != spec.second.toSet() || data.size != spec.second.size) { + throw PushProtocolException("${table.wireName} data does not match registry") + } + if ("deviceId" in key || "deviceId" in data || "synced" in data) { + throw PushProtocolException("batch-scoped or local-only column in record") + } + } + + private fun validateRecordKeys(table: PushAppendTable, key: Map) { + if (key.keys.toList() != REGISTRY.getValue(table.wireName).first) { + throw PushProtocolException("${table.wireName} key does not match registry") + } + } + + private fun validateUuid(value: String, name: String) { + if (runCatching { UUID.fromString(value).toString() }.getOrNull() != value) { + throw PushProtocolException("$name must be a lowercase canonical UUID") + } + } + + private fun sha256Hex(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256") + .digest(bytes).joinToString("") { "%02x".format(it.toInt() and 0xff) } + + private const val UUID_PLACEHOLDER = "00000000-0000-0000-0000-000000000000" + + private val REGISTRY: Map, List>> = mapOf( + "hrSample" to (listOf("ts") to listOf("bpm")), + "rrInterval" to (listOf("ts", "rrMs", "seq") to listOf("ord", "srcChannel", "tsSuspect")), + "event" to (listOf("ts", "kind") to listOf("payloadJSON")), + "battery" to (listOf("ts") to listOf("soc", "mv", "charging")), + "spo2Sample" to (listOf("ts") to listOf("red", "ir")), + "skinTempSample" to (listOf("ts") to listOf("raw", "aux1Raw", "aux2Raw")), + "respSample" to (listOf("ts") to listOf("raw")), + "gravitySample" to (listOf("ts") to listOf("x", "y", "z", "dynAccel")), + "dailyMetric" to (listOf("day") to listOf( + "totalSleepMin", "efficiency", "deepMin", "remMin", "lightMin", "disturbances", + "restingHr", "avgHrv", "recovery", "strain", "exerciseCount", "spo2Pct", + "skinTempDevC", "respRateBpm", "steps", "activeKcalEst", "spo2Red", "spo2Ir", + )), + "sleepSession" to (listOf("startTs") to listOf( + "endTs", "efficiency", "restingHr", "avgHrv", "stagesJSON", "userEdited", + "startTsAdjusted", "motionJSON", "sleepStateJSON", "stagingSparse", + )), + "workout" to (listOf("startTs", "sport") to listOf( + "endTs", "source", "durationS", "energyKcal", "avgHr", "maxHr", "strain", + "distanceM", "zonesJSON", "notes", "routePolyline", "steps", + )), + "journal" to (listOf("day", "question") to listOf("answeredYes", "notes", "numericValue")), + ) +} + +data class PushAck( + val protocolVersion: String, + val batchId: String, + val stream: String, + val deviceId: String, + val endCursor: PushCursor?, + val acceptedRows: Int, + val status: String, +) { + fun encode(): ByteArray = PushProtocol.canonicalJson( + mapOf( + "acceptedRows" to acceptedRows, + "batchId" to batchId, + "deviceId" to deviceId, + "endCursor" to endCursor?.let { + mapOf("keySha256" to it.naturalKeyFingerprint, "rowId" to it.rowId) + }, + "protocolVersion" to protocolVersion, + "status" to status, + "stream" to stream, + ), + ).toByteArray(Charsets.UTF_8) + + fun exactlyMatches(batch: PushBatch): Boolean = + protocolVersion == batch.protocolVersion && batchId == batch.batchId && + stream == batch.table.wireName && deviceId == batch.deviceId && + endCursor == batch.endCursor && acceptedRows == batch.recordCount && status == "accepted" + + companion object { + fun fromBatch(batch: PushBatch): PushAck = PushAck( + batch.protocolVersion, batch.batchId, batch.table.wireName, batch.deviceId, + batch.endCursor, batch.recordCount, "accepted", + ) + + fun parse(bytes: ByteArray): PushAck { + if (bytes.size > PushProtocol.MAX_ACK_BYTES) throw PushProtocolException("ack exceeds size limit") + val obj = try { + JSONObject(bytes.toString(Charsets.UTF_8)) + } catch (_: Throwable) { + throw PushProtocolException("ack is not valid JSON") + } + val expectedMembers = setOf( + "protocolVersion", "batchId", "stream", "deviceId", "endCursor", + "acceptedRows", "status", + ) + val actualMembers = obj.keys().asSequence().toSet() + if (!actualMembers.containsAll(expectedMembers)) { + throw PushProtocolException("ack is missing required protocol 1.0 members") + } + if (actualMembers.any { it in PushProtocol.FORBIDDEN_REMOTE_CONTROL_MEMBERS }) { + throw PushProtocolException("ack contains forbidden remote-control metadata") + } + fun string(name: String): String = (obj.opt(name) as? String)?.takeIf { it.isNotEmpty() } + ?: throw PushProtocolException("ack.$name must be a non-empty string") + fun int(name: String): Int { + val number = obj.opt(name) as? Number ?: throw PushProtocolException("ack.$name must be an integer") + val long = number.toLong() + if (number.toDouble() != long.toDouble() || long !in Int.MIN_VALUE..Int.MAX_VALUE) { + throw PushProtocolException("ack.$name must be an integer") + } + return long.toInt() + } + val cursor = when (val raw = obj.opt("endCursor")) { + null, JSONObject.NULL -> null + is JSONObject -> { + if (!raw.keys().asSequence().toSet().containsAll(setOf("rowId", "keySha256"))) { + throw PushProtocolException("ack.endCursor is missing required protocol 1.0 members") + } + val row = raw.opt("rowId") as? Number + ?: throw PushProtocolException("ack.endCursor.rowId must be an integer") + val rowId = row.toLong() + if (row.toDouble() != rowId.toDouble()) { + throw PushProtocolException("ack.endCursor.rowId must be an integer") + } + val sha = (raw.opt("keySha256") as? String)?.takeIf { it.matches(Regex("[0-9a-f]{64}")) } + ?: throw PushProtocolException("ack.endCursor.keySha256 must be lowercase SHA-256") + PushCursor(rowId, sha) + } + else -> throw PushProtocolException("ack.endCursor must be an object or null") + } + return PushAck( + protocolVersion = string("protocolVersion"), + batchId = string("batchId"), + stream = string("stream"), + deviceId = string("deviceId"), + endCursor = cursor, + acceptedRows = int("acceptedRows"), + status = string("status"), + ) + } + } +} diff --git a/android/app/src/main/java/com/noop/push/PushRunSignal.kt b/android/app/src/main/java/com/noop/push/PushRunSignal.kt new file mode 100644 index 0000000000..fd00967bd2 --- /dev/null +++ b/android/app/src/main/java/com/noop/push/PushRunSignal.kt @@ -0,0 +1,123 @@ +package com.noop.push + +import android.content.Context +import android.content.SharedPreferences + +/** Durable owner/pending handshake spanning reservation, RUNNING, and WorkManager BACKOFF. */ +internal object PushRunSignal { + data class Settlement(val owned: Boolean, val pending: Boolean) + private const val PREFS = "self_hosted_push_signal" + private const val KEY_OWNER = "owner" + private const val KEY_STATE = "state" + private const val KEY_RESERVED_AT = "reserved_at" + private const val KEY_PENDING = "pending" + private const val STATE_RESERVED = "reserved" + private const val STATE_RUNNING = "running" + private const val STATE_BACKOFF = "backoff" + private const val RESERVATION_TTL_MILLIS = 2 * 60 * 1_000L + private val lock = Any() + + /** Atomically reserves an empty slot, expires an abandoned reservation, or coalesces a trigger. */ + fun reserve(context: Context, requestId: String): Boolean = + reserve(prefs(context), requestId, System.currentTimeMillis()) + + internal fun reserve(prefs: SharedPreferences, requestId: String, now: Long = 0L): Boolean = synchronized(lock) { + val owner = prefs.getString(KEY_OWNER, null) + val staleReservation = owner != null && prefs.getString(KEY_STATE, null) == STATE_RESERVED && + now - prefs.getLong(KEY_RESERVED_AT, now) >= RESERVATION_TTL_MILLIS + if (owner != null && !staleReservation) { + check(prefs.edit().putBoolean(KEY_PENDING, true).commit()) { "Could not persist push trigger" } + false + } else { + check(prefs.edit().putString(KEY_OWNER, requestId).putString(KEY_STATE, STATE_RESERVED) + .putLong(KEY_RESERVED_AT, now).putBoolean(KEY_PENDING, false).commit()) { + "Could not reserve push work" + } + true + } + } + + fun begin(context: Context, requestId: String) = begin(prefs(context), requestId) + + internal fun begin(prefs: SharedPreferences, requestId: String) = synchronized(lock) { + val owner = prefs.getString(KEY_OWNER, null) + check(owner == null || owner == requestId) { "Push work owner mismatch" } + check(prefs.edit().putString(KEY_OWNER, requestId).putString(KEY_STATE, STATE_RUNNING) + .remove(KEY_RESERVED_AT).commit()) { "Could not persist push worker owner" } + } + + /** Retry retains ownership through BACKOFF; terminal completion compare-and-clears this owner. */ + fun finish(context: Context, requestId: String, willRetry: Boolean): Boolean = + finish(prefs(context), requestId, willRetry) + + internal fun finish(prefs: SharedPreferences, requestId: String, willRetry: Boolean): Boolean = + settle(prefs, requestId, willRetry) {}.pending + + /** + * Reads pending, commits the caller's status, then releases/transitions the owner under one lock. + * A trigger can therefore land either before the status decision (and force CONTINUING) or after + * release (and write QUEUED itself), but an old worker can never overwrite the newer trigger. + */ + fun settle( + context: Context, + requestId: String, + willRetry: Boolean, + updateStatus: (pending: Boolean) -> Unit, + ): Settlement = settle(prefs(context), requestId, willRetry, updateStatus) + + internal fun settle( + prefs: SharedPreferences, + requestId: String, + willRetry: Boolean, + updateStatus: (pending: Boolean) -> Unit, + ): Settlement = synchronized(lock) { + if (prefs.getString(KEY_OWNER, null) != requestId) return@synchronized Settlement(false, false) + val pending = prefs.getBoolean(KEY_PENDING, false) + updateStatus(pending) + // Only a genuine failure retry keeps this request as owner through WorkManager backoff. + // Healthy continuation returns success and is queued as a fresh request by the caller. + val keepOwner = willRetry + val edit = prefs.edit().putBoolean(KEY_PENDING, false).remove(KEY_RESERVED_AT) + if (keepOwner) edit.putString(KEY_STATE, STATE_BACKOFF) else edit.remove(KEY_OWNER).remove(KEY_STATE) + check(edit.commit()) { "Could not finish push worker owner" } + Settlement(true, pending) + } + + /** Safe before begin: a failed enqueue cannot clear a running or backoff owner. */ + fun releaseReservation(context: Context, requestId: String): Boolean = + releaseReservation(prefs(context), requestId) {} + + internal fun releaseReservation(prefs: SharedPreferences, requestId: String): Boolean = + releaseReservation(prefs, requestId) {} + + fun releaseReservation( + context: Context, + requestId: String, + updateStatus: (pending: Boolean) -> Unit, + ): Boolean = releaseReservation(prefs(context), requestId, updateStatus) + + internal fun releaseReservation( + prefs: SharedPreferences, + requestId: String, + updateStatus: (pending: Boolean) -> Unit, + ): Boolean = synchronized(lock) { + if (prefs.getString(KEY_OWNER, null) == requestId && prefs.getString(KEY_STATE, null) == STATE_RESERVED) { + val pending = prefs.getBoolean(KEY_PENDING, false) + // Status is committed while this owner still excludes a successor. A new QUEUED owner + // can therefore only write after this failed owner's status decision is finished. + updateStatus(pending) + check(prefs.edit().remove(KEY_OWNER).remove(KEY_STATE).remove(KEY_RESERVED_AT).remove(KEY_PENDING).commit()) { + "Could not release push reservation" + } + pending + } else { + false + } + } + + fun clear(context: Context) = clear(prefs(context)) + internal fun clear(prefs: SharedPreferences) = synchronized(lock) { prefs.edit().clear().apply() } + + private fun prefs(context: Context) = + context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) +} diff --git a/android/app/src/main/java/com/noop/push/PushWorkerGate.kt b/android/app/src/main/java/com/noop/push/PushWorkerGate.kt new file mode 100644 index 0000000000..e8119ed191 --- /dev/null +++ b/android/app/src/main/java/com/noop/push/PushWorkerGate.kt @@ -0,0 +1,23 @@ +package com.noop.push + +/** Ordering seam: stale/disabled work exits before network, Keystore, Room, or HTTP are touched. */ +internal object PushWorkerGate { + sealed interface Outcome { + data object DisabledOrInvalid : Outcome + data object NetworkUnavailable : Outcome + data object MissingToken : Outcome + data class Executed(val retry: Boolean) : Outcome + } + + suspend fun run( + enabledEndpoint: () -> PushEndpointPolicy.ValidEndpoint?, + networkAvailable: () -> Boolean, + token: () -> String?, + execute: suspend (PushEndpointPolicy.ValidEndpoint, String) -> Boolean, + ): Outcome { + val endpoint = enabledEndpoint() ?: return Outcome.DisabledOrInvalid + if (!networkAvailable()) return Outcome.NetworkUnavailable + val bearer = token() ?: return Outcome.MissingToken + return Outcome.Executed(retry = execute(endpoint, bearer)) + } +} diff --git a/android/app/src/main/java/com/noop/push/SelfHostedPushScheduler.kt b/android/app/src/main/java/com/noop/push/SelfHostedPushScheduler.kt new file mode 100644 index 0000000000..ad23445897 --- /dev/null +++ b/android/app/src/main/java/com/noop/push/SelfHostedPushScheduler.kt @@ -0,0 +1,165 @@ +package com.noop.push + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.await +import com.noop.R +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +/** Exactly-once cleanup seam shared by synchronous throws and asynchronous Operation failures. */ +internal class PushEnqueueCompletion( + private val settleFailure: () -> Boolean, + private val requeuePending: () -> Unit, +) { + private val completed = AtomicBoolean(false) + @Volatile var requeuedPending: Boolean = false + private set + + fun success(): Boolean = completed.compareAndSet(false, true) + + fun failure(): Boolean { + if (!completed.compareAndSet(false, true)) return false + val pending = settleFailure() + if (pending) { + requeuedPending = true + requeuePending() + } + return true + } +} + +internal fun continuationEnqueueCompletion( + settleFailure: () -> Boolean, + requeuePending: () -> Unit, +) = PushEnqueueCompletion(settleFailure, requeuePending) + +internal fun requiredPushNetworkType(wifiOnly: Boolean): NetworkType = + if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED + +/** The only entry point that queues pushes. Unique work serialises every trigger to one worker. */ +object SelfHostedPushScheduler { + internal const val UNIQUE_WORK = "self-hosted-health-push" + internal const val BACKOFF_SECONDS = 30L + // A new true offload supersedes stale/running work. Cancellable deterministic requests and exact + // acks make replay safe, while REPLACE prevents an unbounded chain of rapid offload triggers. + internal val EXISTING_WORK_POLICY = ExistingWorkPolicy.REPLACE + internal val CONTINUATION_WORK_POLICY = ExistingWorkPolicy.APPEND_OR_REPLACE + + fun enqueueAfterSuccessfulOffload(context: Context) = enqueueExternal(context) + + fun enqueueLaunchCatchUp(context: Context) = enqueueExternal(context) + + fun enqueueManualCatchUp(context: Context) = enqueueExternal(context) + + /** Revoke an in-flight destination snapshot before queueing a baseline for the newly saved URL. */ + fun destinationChanged(context: Context) { + val app = context.applicationContext + PushRunSignal.clear(app) + WorkManager.getInstance(app).cancelUniqueWork(UNIQUE_WORK) + enqueueExternal(app) + } + + /** Replace queued work so a changed network policy takes effect immediately. */ + fun networkPolicyChanged(context: Context) { + val app = context.applicationContext + PushRunSignal.clear(app) + WorkManager.getInstance(app).cancelUniqueWork(UNIQUE_WORK) + enqueueExternal(app) + } + + /** Queue the next healthy pagination/device slice without WorkManager's failure backoff. */ + internal suspend fun enqueueContinuation( + context: Context, + preserveTriggerOnFailure: Boolean = false, + ): Boolean { + val app = context.applicationContext + val settings = SelfHostedPushSettings.from(app) + if (settings.enabledEndpoint() == null) return true + val request = request(settings.wifiOnly()) + // Another real trigger won the release/enqueue race and now owns a queued request. + if (!PushRunSignal.reserve(app, request.id.toString())) return true + val completion = continuationEnqueueCompletion( + settleFailure = { + PushRunSignal.releaseReservation(app, request.id.toString()) || preserveTriggerOnFailure + }, + // A real trigger may have coalesced while Operation.await() was in flight. Preserve it + // as fresh external work; the current worker will observe its owner and return success. + requeuePending = { enqueueExternal(app) }, + ) + return try { + val operation = WorkManager.getInstance(app).enqueueUniqueWork( + UNIQUE_WORK, CONTINUATION_WORK_POLICY, request, + ) + operation.await() + completion.success() + true + } catch (cancelled: CancellationException) { + completion.failure() + throw cancelled + } catch (_: Throwable) { + completion.failure() + completion.requeuedPending + } + } + + fun cancel(context: Context) { + val app = context.applicationContext + // This is the immediate correctness boundary; WorkManager cancellation itself is async. + // Disabled settings make the UI idle now and cause all late worker status writes to no-op. + SelfHostedPushSettings.from(app).setEnabled(false) + PushRunSignal.clear(app) + WorkManager.getInstance(app).cancelUniqueWork(UNIQUE_WORK) + } + + private fun enqueueExternal(context: Context) { + val app = context.applicationContext + val settings = SelfHostedPushSettings.from(app) + if (settings.enabledEndpoint() == null) return + val request = request(settings.wifiOnly()) + if (!PushRunSignal.reserve(app, request.id.toString())) return + val completion = PushEnqueueCompletion( + settleFailure = { + PushRunSignal.releaseReservation(app, request.id.toString()) { pending -> + if (!pending) settings.recordError(app.getString(R.string.push_error_enqueue_failed)) + } + }, + // A trigger coalesced while WorkManager's async Operation was in flight. Recreate it + // after releasing the failed owner; if a newer trigger wins the race, reserve coalesces. + requeuePending = { enqueueExternal(app) }, + ) + try { + settings.recordPushStarted() + val operation = WorkManager.getInstance(app).enqueueUniqueWork(UNIQUE_WORK, EXISTING_WORK_POLICY, request) + enqueueObserverScope.launch { + try { + operation.await() + completion.success() + } catch (_: Throwable) { + completion.failure() + } + } + } catch (_: Throwable) { + completion.failure() + } + } + + private fun request(wifiOnly: Boolean) = OneTimeWorkRequest.Builder(SelfHostedPushWorker::class.java) + .setConstraints( + Constraints.Builder().setRequiredNetworkType(requiredPushNetworkType(wifiOnly)).build(), + ) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS) + .build() // Deliberately no input Data: credentials and endpoint never enter Worker metadata. + + private val enqueueObserverScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) +} diff --git a/android/app/src/main/java/com/noop/push/SelfHostedPushScreen.kt b/android/app/src/main/java/com/noop/push/SelfHostedPushScreen.kt new file mode 100644 index 0000000000..865dcf55f4 --- /dev/null +++ b/android/app/src/main/java/com/noop/push/SelfHostedPushScreen.kt @@ -0,0 +1,368 @@ +package com.noop.push + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CloudUpload +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import com.noop.R +import com.noop.ui.NoopButton +import com.noop.ui.NoopButtonKind +import com.noop.ui.NoopType +import com.noop.ui.Palette +import com.noop.ui.ScreenScaffold +import com.noop.ui.SettingsCard +import com.noop.ui.SettingsToggleRow +import java.text.DateFormat +import java.util.Date +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +private sealed interface CapabilityProbeUi { + data object Idle : CapabilityProbeUi + data object Testing : CapabilityProbeUi + data class Success(val streams: List, val checkedAt: Long) : CapabilityProbeUi + data class Failure(val failure: PushFailure) : CapabilityProbeUi +} + +/** Experimental, explicit consent surface for raw one-way health-data egress. */ +@Composable +fun SelfHostedPushScreen() { + val context = LocalContext.current + val settings = remember { SelfHostedPushSettings.from(context) } + var endpoint by remember { mutableStateOf(settings.endpointText()) } + var token by remember { mutableStateOf("") } + var snapshot by remember { mutableStateOf(settings.snapshot()) } + var validationMessage by remember { mutableStateOf(null) } + var capabilityProbe by remember { mutableStateOf(CapabilityProbeUi.Idle) } + var capabilityProbeGeneration by remember { mutableStateOf(0) } + val scope = rememberCoroutineScope() + + // WorkManager runs outside this composition. Refresh while visible so progress and completion do + // not require navigating away and back (and avoid retaining a UI listener in process globals). + LaunchedEffect(settings) { + while (currentCoroutineContext().isActive) { + snapshot = settings.snapshot() + delay(750) + } + } + + val validatedEndpoint = PushEndpointPolicy.validate(endpoint) as? PushEndpointPolicy.Result.Valid + val endpointValid = validatedEndpoint != null + val tokenAvailable = token.isNotBlank() || snapshot.hasToken + val active = snapshot.runState in setOf( + SelfHostedPushSettings.RunState.QUEUED, + SelfHostedPushSettings.RunState.RUNNING, + SelfHostedPushSettings.RunState.CONTINUING, + SelfHostedPushSettings.RunState.RETRYING, + ) + + ScreenScaffold( + title = stringResource(R.string.push_title), + subtitle = stringResource(R.string.push_subtitle), + ) { + SettingsCard( + icon = Icons.Filled.CloudUpload, + title = stringResource(R.string.push_destination_title), + blurb = stringResource(R.string.push_disclosure), + ) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + stringResource(R.string.push_one_way_warning), + style = NoopType.footnote, + color = Palette.statusWarning, + ) + PushTextField( + value = endpoint, + onValueChange = { + endpoint = it + validationMessage = null + capabilityProbe = CapabilityProbeUi.Idle + capabilityProbeGeneration++ + }, + label = stringResource(R.string.push_endpoint), + secret = false, + ) + PushTextField( + value = token, + onValueChange = { + token = it + capabilityProbe = CapabilityProbeUi.Idle + capabilityProbeGeneration++ + }, + label = if (snapshot.hasToken) stringResource(R.string.push_token_saved) else stringResource(R.string.push_token), + secret = true, + ) + validationMessage?.let { + Text(it, style = NoopType.footnote, color = Palette.statusWarning) + } + NoopButton( + text = stringResource(R.string.push_save), + kind = NoopButtonKind.Secondary, + fullWidth = true, + enabled = endpointValid && tokenAvailable, + onClick = { + when (val result = settings.saveEndpoint(endpoint)) { + is PushEndpointPolicy.Result.Invalid -> + validationMessage = pushEndpointProblemMessage(context, result.problem) + is PushEndpointPolicy.Result.Valid -> { + val destinationChanged = snapshot.endpoint?.url != result.endpoint.url + endpoint = result.endpoint.url + if (token.isNotBlank()) settings.saveToken(token) + token = "" + snapshot = settings.snapshot() + if (destinationChanged && snapshot.ready) { + SelfHostedPushScheduler.destinationChanged(context) + } + validationMessage = context.getString(R.string.push_saved) + } + } + }, + ) + NoopButton( + text = if (capabilityProbe == CapabilityProbeUi.Testing) { + stringResource(R.string.push_testing_connection) + } else { + stringResource(R.string.push_test_connection) + }, + kind = NoopButtonKind.Secondary, + fullWidth = true, + enabled = endpointValid && tokenAvailable && capabilityProbe != CapabilityProbeUi.Testing, + onClick = { + val valid = PushEndpointPolicy.validate(endpoint) as? PushEndpointPolicy.Result.Valid + val testToken = token.trim().takeIf(String::isNotEmpty) ?: settings.token() + if (valid == null || testToken == null) { + validationMessage = context.getString(R.string.push_config_required) + } else if (!canStartPushConnectionTest( + networkAvailable = isPushNetworkAvailable( + context, + wifiOnly = snapshot.wifiOnly, + ), + endpointValid = true, + tokenAvailable = true, + ) + ) { + validationMessage = context.getString(R.string.push_test_network_required) + } else { + val generation = capabilityProbeGeneration + 1 + capabilityProbeGeneration = generation + val persistResult = token.isBlank() && valid.endpoint.url == snapshot.endpoint?.url + capabilityProbe = CapabilityProbeUi.Testing + scope.launch { + val result = PushConnectionTester().test(valid.endpoint, testToken) + if (capabilityProbeGeneration != generation) return@launch + capabilityProbe = when (result) { + is PushCapabilitiesResult.Available -> { + val checkedAt = System.currentTimeMillis() + if (persistResult) runCatching { + settings.recordCapabilities( + valid.endpoint, + result.capabilities, + checkedAt, + ) + } + snapshot = settings.snapshot() + CapabilityProbeUi.Success(result.capabilities.wireNames, checkedAt) + } + is PushCapabilitiesResult.Rejected -> CapabilityProbeUi.Failure( + result.failure ?: PushFailure(PushFailureCode.NETWORK_IO), + ) + } + } + } + }, + ) + Text( + stringResource(R.string.push_test_connection_detail), + style = NoopType.footnote, + color = Palette.textSecondary, + ) + val probeSuccess = capabilityProbe as? CapabilityProbeUi.Success + val savedEndpointMatches = token.isBlank() && + validatedEndpoint?.endpoint?.url == snapshot.endpoint?.url + val shownStreams = probeSuccess?.streams ?: snapshot.supportedStreams.takeIf { savedEndpointMatches } + val capabilitiesCheckedAt = probeSuccess?.checkedAt + ?: snapshot.capabilitiesCheckedAt.takeIf { savedEndpointMatches } + when (val probe = capabilityProbe) { + is CapabilityProbeUi.Failure -> Text( + pushFailureMessage(context, probe.failure), + style = NoopType.footnote, + color = Palette.statusWarning, + ) + CapabilityProbeUi.Testing -> LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = Palette.accent, + ) + else -> Unit + } + shownStreams?.let { streams -> + Text( + stringResource( + R.string.push_capabilities_summary, + streams.size, + PushCapabilities.ALL.wireNames.size, + ), + style = NoopType.body, + color = if (streams.isEmpty()) Palette.statusWarning else Palette.textPrimary, + ) + capabilitiesCheckedAt?.let { checkedAt -> + val checked = DateFormat.getDateTimeInstance( + DateFormat.MEDIUM, + DateFormat.SHORT, + ).format(Date(checkedAt)) + Text( + stringResource(R.string.push_capabilities_checked, checked), + style = NoopType.footnote, + color = Palette.textSecondary, + ) + } + Text( + if (streams.isEmpty()) stringResource(R.string.push_capabilities_none) + else stringResource(R.string.push_capabilities_streams, streams.joinToString(" · ")), + style = NoopType.footnote, + color = if (streams.isEmpty()) Palette.statusWarning else Palette.textSecondary, + ) + } + if (snapshot.hasToken) { + NoopButton( + text = stringResource(R.string.push_clear_token), + kind = NoopButtonKind.Secondary, + fullWidth = true, + onClick = { + settings.saveToken("") + settings.setEnabled(false) + SelfHostedPushScheduler.cancel(context) + snapshot = settings.snapshot() + validationMessage = context.getString(R.string.push_token_cleared) + }, + ) + } + SettingsToggleRow( + title = stringResource(R.string.push_wifi_only), + detail = stringResource(R.string.push_wifi_only_detail), + checked = snapshot.wifiOnly, + onCheckedChange = { requested -> + capabilityProbe = CapabilityProbeUi.Idle + capabilityProbeGeneration++ + settings.setWifiOnly(requested) + SelfHostedPushScheduler.networkPolicyChanged(context) + snapshot = settings.snapshot() + }, + ) + SettingsToggleRow( + title = stringResource(R.string.push_enabled), + detail = stringResource(R.string.push_enabled_detail), + checked = snapshot.enabled, + onCheckedChange = { requested -> + if (!requested) { + settings.setEnabled(false) + SelfHostedPushScheduler.cancel(context) + } else if (!settings.setEnabled(true)) { + validationMessage = context.getString(R.string.push_config_required) + } else { + SelfHostedPushScheduler.enqueueLaunchCatchUp(context) + } + snapshot = settings.snapshot() + }, + ) + NoopButton( + text = stringResource(R.string.push_export_now), + kind = NoopButtonKind.Secondary, + fullWidth = true, + enabled = snapshot.ready, + onClick = { + SelfHostedPushScheduler.enqueueManualCatchUp(context) + snapshot = settings.snapshot() + }, + ) + Text( + stringResource(R.string.push_export_now_detail), + style = NoopType.footnote, + color = Palette.textSecondary, + ) + } + } + + SettingsCard( + icon = Icons.Filled.CloudUpload, + title = stringResource(R.string.push_status_title), + blurb = stringResource(R.string.push_status_detail), + ) { + if (active) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth(), color = Palette.accent) + } + val state = when (snapshot.runState) { + SelfHostedPushSettings.RunState.IDLE -> stringResource(R.string.push_state_idle) + SelfHostedPushSettings.RunState.QUEUED -> stringResource(R.string.push_state_queued) + SelfHostedPushSettings.RunState.RUNNING -> stringResource(R.string.push_state_running) + SelfHostedPushSettings.RunState.CONTINUING -> stringResource(R.string.push_state_continuing) + SelfHostedPushSettings.RunState.RETRYING -> stringResource(R.string.push_state_retrying) + SelfHostedPushSettings.RunState.COMPLETE -> stringResource(R.string.push_state_complete) + SelfHostedPushSettings.RunState.FAILED -> stringResource(R.string.push_state_failed) + } + Text(stringResource(R.string.push_current_state, state), style = NoopType.body, color = Palette.textPrimary) + Text( + stringResource(R.string.push_progress, snapshot.acceptedBatches, snapshot.acceptedRecords), + style = NoopType.footnote, + color = Palette.textSecondary, + ) + snapshot.currentStream?.let { stream -> + Text( + stringResource(R.string.push_current_stream, stream), + style = NoopType.footnote, + color = Palette.textSecondary, + ) + } + val success = snapshot.lastSuccessAt?.let { + DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(it)) + } ?: stringResource(R.string.push_never) + Text(stringResource(R.string.push_last_success, success), style = NoopType.body, color = Palette.textPrimary) + snapshot.lastError?.let { + Text(stringResource(R.string.push_last_error, it), style = NoopType.footnote, color = Palette.statusWarning) + } + } + } +} + +@Composable +private fun PushTextField(value: String, onValueChange: (String) -> Unit, label: String, secret: Boolean) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(label) }, + singleLine = true, + visualTransformation = if (secret) PasswordVisualTransformation() else VisualTransformation.None, + textStyle = NoopType.mono(13f), + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = Palette.textPrimary, + unfocusedTextColor = Palette.textPrimary, + focusedBorderColor = Palette.accent, + unfocusedBorderColor = Palette.hairline, + cursorColor = Palette.accent, + focusedContainerColor = Palette.surfaceInset, + unfocusedContainerColor = Palette.surfaceInset, + ), + ) +} diff --git a/android/app/src/main/java/com/noop/push/SelfHostedPushSettings.kt b/android/app/src/main/java/com/noop/push/SelfHostedPushSettings.kt new file mode 100644 index 0000000000..caddcdb18d --- /dev/null +++ b/android/app/src/main/java/com/noop/push/SelfHostedPushSettings.kt @@ -0,0 +1,299 @@ +package com.noop.push + +import android.content.Context +import android.content.SharedPreferences +import com.noop.data.SecurePrefs +import java.security.MessageDigest +import java.util.UUID + +/** Stores configuration without ever placing the bearer token in ordinary preferences. */ +class SelfHostedPushSettings private constructor( + private val prefs: SharedPreferences, + private val secrets: Lazy, +) { + enum class RunState { IDLE, QUEUED, RUNNING, CONTINUING, RETRYING, COMPLETE, FAILED } + + data class Snapshot( + val enabled: Boolean, + val wifiOnly: Boolean, + val endpoint: PushEndpointPolicy.ValidEndpoint?, + val hasToken: Boolean, + val lastSuccessAt: Long?, + val lastError: String?, + val runState: RunState, + val acceptedBatches: Int, + val acceptedRecords: Long, + val currentStream: String?, + val supportedStreams: List?, + val capabilitiesCheckedAt: Long?, + ) { + val ready: Boolean get() = enabled && endpoint != null && hasToken + } + + fun snapshot(): Snapshot { + val enabled = prefs.getBoolean(KEY_ENABLED, false) + val endpoint = (PushEndpointPolicy.validate(prefs.getString(KEY_ENDPOINT, "").orEmpty()) as? PushEndpointPolicy.Result.Valid)?.endpoint + val capabilities = capabilitiesFor(endpoint) + return Snapshot( + enabled = enabled, + wifiOnly = wifiOnly(), + endpoint = endpoint, + hasToken = !secrets.value.getString(KEY_TOKEN, null).isNullOrBlank(), + lastSuccessAt = prefs.getLong(KEY_LAST_SUCCESS, 0L).takeIf { it > 0 }, + lastError = prefs.getString(KEY_LAST_ERROR, null), + runState = if (!enabled) RunState.IDLE else runCatching { + RunState.valueOf(prefs.getString(KEY_RUN_STATE, RunState.IDLE.name).orEmpty()) + }.getOrDefault(RunState.IDLE), + acceptedBatches = prefs.getInt(KEY_ACCEPTED_BATCHES, 0).coerceAtLeast(0), + acceptedRecords = prefs.getLong(KEY_ACCEPTED_RECORDS, 0L).coerceAtLeast(0L), + currentStream = prefs.getString(KEY_CURRENT_STREAM, null), + supportedStreams = capabilities, + capabilitiesCheckedAt = prefs.getLong(KEY_CAPABILITIES_AT, 0L) + .takeIf { it > 0 && capabilities != null }, + ) + } + + fun endpointText(): String = prefs.getString(KEY_ENDPOINT, "").orEmpty() + fun wifiOnly(): Boolean = prefs.getBoolean(KEY_WIFI_ONLY, true) + + fun setWifiOnly(wifiOnly: Boolean) { + check(prefs.edit().putBoolean(KEY_WIFI_ONLY, wifiOnly).commit()) { + "Could not persist push network policy" + } + } + + /** Plain-pref gate used by stale workers before opening Room or Android Keystore. */ + fun enabledEndpoint(): PushEndpointPolicy.ValidEndpoint? { + if (!prefs.getBoolean(KEY_ENABLED, false)) return null + return (PushEndpointPolicy.validate(endpointText()) as? PushEndpointPolicy.Result.Valid)?.endpoint + } + + fun token(): String? = secrets.value.getString(KEY_TOKEN, null)?.takeIf { it.isNotBlank() } + + /** Stable, non-secret receiver namespace. Generated only after the worker's stale-work gates pass. */ + @Synchronized + fun sourceId(): String { + prefs.getString(KEY_SOURCE_ID, null)?.let { existing -> + runCatching { UUID.fromString(existing) }.getOrNull()?.let { return it.toString() } + } + val generated = UUID.randomUUID().toString() + check(prefs.edit().putString(KEY_SOURCE_ID, generated).commit()) { "Could not persist push source id" } + return generated + } + + /** Saving a different normalized URL changes the progress namespace; token rotation does not. */ + fun saveEndpoint(raw: String): PushEndpointPolicy.Result { + val validation = PushEndpointPolicy.validate(raw) + val normalized = (validation as? PushEndpointPolicy.Result.Valid)?.endpoint?.url + ?: return validation + val edit = prefs.edit().putString(KEY_ENDPOINT, normalized) + if (prefs.getString(KEY_CAPABILITIES_ENDPOINT, null) != normalized) { + edit.remove(KEY_CAPABILITIES_ENDPOINT) + .remove(KEY_CAPABILITIES_STREAMS) + .remove(KEY_CAPABILITIES_AT) + } + edit.apply() + return validation + } + + fun saveToken(token: String) { + val trimmed = token.trim() + val changed = secrets.value.getString(KEY_TOKEN, null).orEmpty() != trimmed + secrets.value.edit().let { edit -> + if (trimmed.isEmpty()) edit.remove(KEY_TOKEN) else edit.putString(KEY_TOKEN, trimmed) + }.apply() + if (changed) clearCapabilities() + } + + fun setEnabled(enabled: Boolean): Boolean = synchronized(statusLock) { + if (enabled && !snapshot().copy(enabled = true).ready) return@synchronized false + val edit = prefs.edit().putBoolean(KEY_ENABLED, enabled) + if (!enabled) edit.putString(KEY_RUN_STATE, RunState.IDLE.name) + .remove(KEY_LAST_ERROR).remove(KEY_CURRENT_STREAM) + check(edit.commit()) { "Could not persist push enabled state" } + true + } + + fun progressNamespace( + sourceId: String, + endpoint: PushEndpointPolicy.ValidEndpoint, + protocolVersion: String = PushProtocol.VERSION, + receiverStateId: String = PushCapabilities.UNSCOPED_RECEIVER_STATE_ID, + ): String = + MessageDigest.getInstance("SHA-256").digest( + "$sourceId\u0000${endpoint.url}\u0000$protocolVersion\u0000$receiverStateId".toByteArray(), + ) + .take(12).joinToString("") { "%02x".format(it) } + + fun recordSuccess(atMillis: Long = System.currentTimeMillis()) = updateWhileEnabled { + it.putLong(KEY_LAST_SUCCESS, atMillis).remove(KEY_LAST_ERROR) + .remove(KEY_CURRENT_STREAM) + .putString(KEY_RUN_STATE, RunState.COMPLETE.name) + } + + fun recordError(message: String) = updateWhileEnabled { + it.putString(KEY_LAST_ERROR, message.take(MAX_STATUS_CHARS)) + .putString(KEY_RUN_STATE, RunState.FAILED.name) + } + + /** Starts a new logical catch-up. Continuation workers deliberately do not reset these counters. */ + fun recordPushStarted() = updateWhileEnabled { + it.remove(KEY_LAST_ERROR) + .remove(KEY_CURRENT_STREAM) + .putInt(KEY_ACCEPTED_BATCHES, 0).putLong(KEY_ACCEPTED_RECORDS, 0L) + .putString(KEY_RUN_STATE, RunState.QUEUED.name) + } + + fun recordRunning() = updateWhileEnabled { it.putString(KEY_RUN_STATE, RunState.RUNNING.name) } + + fun recordCurrentStream(stream: String) = updateWhileEnabled { + require(stream in PushCapabilities.ALL.wireNames) { "unknown push stream" } + if (prefs.getString(KEY_CURRENT_STREAM, null) == stream) return@updateWhileEnabled it + it.putString(KEY_CURRENT_STREAM, stream) + } + + fun recordCapabilities( + endpoint: PushEndpointPolicy.ValidEndpoint, + capabilities: PushCapabilities, + atMillis: Long = System.currentTimeMillis(), + ) = synchronized(statusLock) { + check(prefs.edit() + .putString(KEY_CAPABILITIES_ENDPOINT, endpoint.url) + .putString(KEY_CAPABILITIES_STREAMS, capabilities.wireNames.joinToString(",")) + .putLong(KEY_CAPABILITIES_AT, atMillis) + .commit()) { "Could not persist receiver capabilities" } + } + + @Synchronized + fun recordAcceptedBatches(batches: Int, records: Long = 0L) = synchronized(statusLock) { + if (batches <= 0 && records <= 0) return + if (!prefs.getBoolean(KEY_ENABLED, false)) return + check(prefs.edit() + .putInt( + KEY_ACCEPTED_BATCHES, + prefs.getInt(KEY_ACCEPTED_BATCHES, 0).coerceAtLeast(0) + batches.coerceAtLeast(0), + ) + .putLong( + KEY_ACCEPTED_RECORDS, + prefs.getLong(KEY_ACCEPTED_RECORDS, 0L).coerceAtLeast(0L) + records.coerceAtLeast(0L), + ) + .commit()) { "Could not persist push progress" } + } + + /** Pagination and device rotation are healthy progress, never an error. */ + fun recordContinuation() = updateWhileEnabled { + it.remove(KEY_LAST_ERROR).remove(KEY_CURRENT_STREAM) + .putString(KEY_RUN_STATE, RunState.CONTINUING.name) + } + + fun recordRetrying(message: String) = updateWhileEnabled { + it.putString(KEY_LAST_ERROR, message.take(MAX_STATUS_CHARS)) + .putString(KEY_RUN_STATE, RunState.RETRYING.name) + } + + private inline fun updateWhileEnabled(change: (SharedPreferences.Editor) -> SharedPreferences.Editor) = + synchronized(statusLock) { + if (!prefs.getBoolean(KEY_ENABLED, false)) return@synchronized + check(change(prefs.edit()).commit()) { "Could not persist push status" } + } + + private fun capabilitiesFor(endpoint: PushEndpointPolicy.ValidEndpoint?): List? { + if (endpoint == null || prefs.getString(KEY_CAPABILITIES_ENDPOINT, null) != endpoint.url) return null + if (!prefs.contains(KEY_CAPABILITIES_STREAMS)) return null + val encoded = prefs.getString(KEY_CAPABILITIES_STREAMS, "").orEmpty() + if (encoded.isEmpty()) return emptyList() + val names = encoded.split(',') + val known = PushCapabilities.ALL.wireNames.toSet() + return names.takeIf { it.size == it.distinct().size && it.all(known::contains) } + } + + private fun clearCapabilities() { + prefs.edit().remove(KEY_CAPABILITIES_ENDPOINT) + .remove(KEY_CAPABILITIES_STREAMS).remove(KEY_CAPABILITIES_AT).apply() + } + + fun nextDeviceIndex(namespace: String): Int = prefs.getInt("$KEY_NEXT_DEVICE.$namespace", 0).coerceAtLeast(0) + + fun saveNextDeviceIndex(namespace: String, index: Int) { + require(index >= 0) + prefs.edit().putInt("$KEY_NEXT_DEVICE.$namespace", index).apply() + } + + fun cycleNeedsAnotherPass(namespace: String): Boolean = + prefs.getBoolean("$KEY_CYCLE_MORE.$namespace", false) + + fun saveCycleNeedsAnotherPass(namespace: String, needed: Boolean) { + prefs.edit().putBoolean("$KEY_CYCLE_MORE.$namespace", needed).apply() + } + + fun cycleHadRejection(namespace: String): Boolean = prefs.getBoolean("$KEY_CYCLE_REJECTED.$namespace", false) + + fun saveCycleHadRejection(namespace: String, rejected: Boolean) { + prefs.edit().putBoolean("$KEY_CYCLE_REJECTED.$namespace", rejected).apply() + } + + /** Persists only a safe category/status, never an exception message or response body. */ + fun cycleFailure(namespace: String): PushFailure? { + val code = prefs.getString("$KEY_CYCLE_FAILURE_CODE.$namespace", null) + ?.let { stored -> PushFailureCode.entries.firstOrNull { it.name == stored } } + ?: return null + val status = prefs.getInt("$KEY_CYCLE_FAILURE_STATUS.$namespace", 0) + .takeIf { it in 100..599 } + val receiverCode = prefs.getString("$KEY_CYCLE_FAILURE_RECEIVER_CODE.$namespace", null) + ?.takeIf { it.matches(Regex("[a-z][a-z0-9_]{0,63}")) } + return PushFailure(code, status, receiverCode) + } + + fun saveCycleFailure(namespace: String, failure: PushFailure?) { + val editor = prefs.edit() + if (failure == null) { + editor.remove("$KEY_CYCLE_FAILURE_CODE.$namespace") + .remove("$KEY_CYCLE_FAILURE_STATUS.$namespace") + .remove("$KEY_CYCLE_FAILURE_RECEIVER_CODE.$namespace") + } else { + editor.putString("$KEY_CYCLE_FAILURE_CODE.$namespace", failure.code.name) + failure.httpStatus?.let { + editor.putInt("$KEY_CYCLE_FAILURE_STATUS.$namespace", it) + } ?: editor.remove("$KEY_CYCLE_FAILURE_STATUS.$namespace") + failure.receiverCode?.let { + editor.putString("$KEY_CYCLE_FAILURE_RECEIVER_CODE.$namespace", it) + } ?: editor.remove("$KEY_CYCLE_FAILURE_RECEIVER_CODE.$namespace") + } + check(editor.commit()) { "Could not persist push failure category" } + } + + companion object { + private const val PREFS = "self_hosted_push" + private const val SECRETS = "self_hosted_push_secrets" + private const val KEY_ENABLED = "enabled" + private const val KEY_WIFI_ONLY = "wifi_only" + private const val KEY_ENDPOINT = "endpoint" + private const val KEY_TOKEN = "bearer_token" + private const val KEY_SOURCE_ID = "source_id" + private const val KEY_LAST_SUCCESS = "last_success_at" + private const val KEY_LAST_ERROR = "last_error" + private const val KEY_RUN_STATE = "run_state" + private const val KEY_ACCEPTED_BATCHES = "accepted_batches" + private const val KEY_ACCEPTED_RECORDS = "accepted_records" + private const val KEY_CURRENT_STREAM = "current_stream" + private const val KEY_CAPABILITIES_ENDPOINT = "capabilities_endpoint" + private const val KEY_CAPABILITIES_STREAMS = "capabilities_streams" + private const val KEY_CAPABILITIES_AT = "capabilities_at" + private const val KEY_NEXT_DEVICE = "next_device" + private const val KEY_CYCLE_MORE = "cycle_more" + private const val KEY_CYCLE_REJECTED = "cycle_rejected" + private const val KEY_CYCLE_FAILURE_CODE = "cycle_failure_code" + private const val KEY_CYCLE_FAILURE_STATUS = "cycle_failure_status" + private const val KEY_CYCLE_FAILURE_RECEIVER_CODE = "cycle_failure_receiver_code" + private const val MAX_STATUS_CHARS = 300 + private val statusLock = Any() + + fun from(context: Context) = SelfHostedPushSettings( + context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE), + lazy(LazyThreadSafetyMode.SYNCHRONIZED) { SecurePrefs.of(context.applicationContext, SECRETS) }, + ) + + internal fun forTest(prefs: SharedPreferences, secrets: SharedPreferences) = + SelfHostedPushSettings(prefs, lazyOf(secrets)) + } +} diff --git a/android/app/src/main/java/com/noop/push/SelfHostedPushWorker.kt b/android/app/src/main/java/com/noop/push/SelfHostedPushWorker.kt new file mode 100644 index 0000000000..d1a97537f3 --- /dev/null +++ b/android/app/src/main/java/com/noop/push/SelfHostedPushWorker.kt @@ -0,0 +1,319 @@ +package com.noop.push + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import androidx.work.CoroutineWorker +import androidx.work.ListenableWorker +import androidx.work.WorkerParameters +import com.noop.R +import com.noop.data.WhoopDatabase +import java.time.LocalDate +import java.time.ZoneId +import kotlinx.coroutines.CancellationException + +internal fun persistedDeviceIndex(startDeviceIndex: Int, nextDeviceIndex: Int, retryableFailure: Boolean): Int = + if (retryableFailure) startDeviceIndex else nextDeviceIndex +internal const val PUSH_MAX_ATTEMPTS = 32 +internal fun shouldRetryPush(runAttemptCount: Int): Boolean = runAttemptCount + 1 < PUSH_MAX_ATTEMPTS +internal fun resultAfterScheduledContinuation( + current: ListenableWorker.Result, + scheduled: Boolean, +): ListenableWorker.Result = if (scheduled) ListenableWorker.Result.success() else current +internal fun successorOwnsEnqueueFailure(currentRequestCouldReserve: Boolean): Boolean = + !currentRequestCouldReserve +internal fun shouldScheduleLatePendingSuccessor(willRetry: Boolean, settlementPending: Boolean): Boolean = + !willRetry && settlementPending +internal fun isPushNetworkAvailable( + wifiOnly: Boolean, + isConnected: Boolean, + isWifi: Boolean, + isUnmetered: Boolean, +): Boolean = isConnected && (!wifiOnly || (isWifi && isUnmetered)) +internal fun isPushNetworkAvailable(context: Context, wifiOnly: Boolean): Boolean { + val connectivity = context.getSystemService(ConnectivityManager::class.java) ?: return false + val network = connectivity.activeNetwork ?: return false + val capabilities = connectivity.getNetworkCapabilities(network) ?: return false + return isPushNetworkAvailable( + wifiOnly = wifiOnly, + isConnected = true, + isWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI), + isUnmetered = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED), + ) +} + +/** One bounded coordinator run. Unique WorkManager work and the trigger lease keep it serial. */ +class SelfHostedPushWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + private data class Decision( + val result: Result, + val willRetry: Boolean, + val continueNormally: Boolean = false, + val status: Status = Status.NONE, + val message: String? = null, + ) + + private data class ExecutionOutcome( + val state: Execution, + val failure: PushFailure? = null, + ) + + private enum class Execution { + COMPLETE, + CONTINUE, + RETRY_FAILURE, + CAPABILITY_TERMINAL_FAILURE, + TERMINAL_FAILURE, + } + private enum class Status { NONE, SUCCESS, CONTINUING, RETRYING, FAILED } + + override suspend fun doWork(): Result { + val settings = SelfHostedPushSettings.from(applicationContext) + // A stale request after disable exits before lease writes, network checks, Keystore, Room, or HTTP. + val requestId = id.toString() + if (settings.enabledEndpoint() == null) { + PushRunSignal.releaseReservation(applicationContext, requestId) + return Result.success() + } + var ownerFinished = false + try { + PushRunSignal.begin(applicationContext, requestId) + settings.recordRunning() + var execution = ExecutionOutcome(Execution.COMPLETE) + var decision = try { + when (val outcome = PushWorkerGate.run( + enabledEndpoint = settings::enabledEndpoint, + networkAvailable = { + isPushNetworkAvailable(applicationContext, wifiOnly = settings.wifiOnly()) + }, + token = settings::token, + execute = { endpoint, token -> + execution = executeOnce(settings, endpoint, token) + execution.state == Execution.RETRY_FAILURE + }, + )) { + PushWorkerGate.Outcome.DisabledOrInvalid -> Decision(Result.success(), false) + PushWorkerGate.Outcome.MissingToken -> Decision( + Result.failure(), false, status = Status.FAILED, + message = applicationContext.getString(R.string.push_error_missing_token), + ) + PushWorkerGate.Outcome.NetworkUnavailable -> retryOrStop( + applicationContext.getString(R.string.push_error_network), + ) + is PushWorkerGate.Outcome.Executed -> when { + outcome.retry -> retryOrStop(failureMessage(execution.failure)) + execution.state == Execution.CONTINUE -> Decision( + Result.success(), false, continueNormally = true, status = Status.CONTINUING, + ) + execution.state == Execution.CAPABILITY_TERMINAL_FAILURE -> Decision( + Result.failure(), false, status = Status.FAILED, + message = failureMessage(execution.failure), + ) + execution.state == Execution.TERMINAL_FAILURE -> Decision( + Result.failure(), false, status = Status.FAILED, + message = failureMessage(execution.failure), + ) + else -> Decision(Result.success(), false, status = Status.SUCCESS) + } + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + // Deliberately generic: exception strings from TLS/HTTP stacks may include destination data. + retryOrStop(applicationContext.getString(R.string.push_error_start)) + } + val settlement = PushRunSignal.settle( + applicationContext, requestId, willRetry = decision.willRetry, + ) { pending -> recordSettledStatus(settings, decision, pending) } + ownerFinished = true + // Healthy pagination/device rotation is a fresh successful work item. This deliberately + // avoids WorkManager retry/backoff, which is reserved for real network/HTTP failures. + val needsContinuation = !decision.willRetry && (decision.continueNormally || settlement.pending) + if (needsContinuation && !SelfHostedPushScheduler.enqueueContinuation(applicationContext)) { + // The append Operation itself failed asynchronously. Re-arm THIS WorkRequest so + // WorkManager's bounded runAttemptCount/backoff handles the infrastructure failure. + val enqueueRetry = retryOrStop(applicationContext.getString(R.string.push_error_queue)) + val currentCouldReserve = PushRunSignal.reserve(applicationContext, requestId) + if (successorOwnsEnqueueFailure(currentCouldReserve)) { + // The preserved pending trigger already owns QUEUED work. Do not let this old + // failure (especially terminal attempt 31) overwrite its status or block it. + return Result.success() + } + PushRunSignal.begin(applicationContext, requestId) + val enqueueFailureSettlement = PushRunSignal.settle( + applicationContext, requestId, willRetry = enqueueRetry.willRetry, + ) { pending -> recordSettledStatus(settings, enqueueRetry, pending) } + if (shouldScheduleLatePendingSuccessor( + enqueueRetry.willRetry, + enqueueFailureSettlement.pending, + ) + ) { + val scheduled = SelfHostedPushScheduler.enqueueContinuation( + applicationContext, + preserveTriggerOnFailure = true, + ) + // A terminal attempt must succeed only when its APPEND prerequisite was actually + // installed (or another owner won inside enqueueContinuation). No retry reset. + return resultAfterScheduledContinuation(enqueueRetry.result, scheduled) + } + return enqueueRetry.result + } + // APPEND successors depend on this WorkSpec succeeding. A fresh pending trigger must not + // be left BLOCKED behind a terminal failure from the snapshot that preceded that trigger. + return resultAfterScheduledContinuation(decision.result, scheduled = needsContinuation) + } finally { + if (!ownerFinished) runCatching { + PushRunSignal.finish(applicationContext, requestId, willRetry = false) + } + } + } + + private suspend fun executeOnce( + settings: SelfHostedPushSettings, + endpoint: PushEndpointPolicy.ValidEndpoint, + token: String, + ): ExecutionOutcome { + val sourceId = settings.sourceId() + // Derive progress from the exact endpoint captured by the stale-work gate. Re-reading prefs + // here could otherwise pair an E1 HTTP request with E2 cursor state during a concurrent edit. + val transport = PushHttpTransport(endpoint, token) { batch -> + settings.recordCurrentStream(batch.table.wireName) + } + val capabilities = when (val result = transport.capabilities()) { + is PushCapabilitiesResult.Available -> result.capabilities + is PushCapabilitiesResult.Rejected -> { + return if (result.retryable) { + ExecutionOutcome( + Execution.RETRY_FAILURE, + result.failure ?: PushFailure(PushFailureCode.NETWORK_IO), + ) + } else { + ExecutionOutcome( + Execution.CAPABILITY_TERMINAL_FAILURE, + result.failure ?: PushFailure(PushFailureCode.CAPABILITIES_INVALID), + ) + } + } + } + runCatching { settings.recordCapabilities(endpoint, capabilities) } + val namespace = settings.progressNamespace( + sourceId, + endpoint, + capabilities.protocolVersion, + capabilities.receiverStateId, + ) + // Capability discovery deliberately precedes Room: unsupported streams cause no table scan, + // snapshot allocation, encoding, or POST. An empty allowlist is a valid caught-up receiver. + if (capabilities.isEmpty) { + settings.saveNextDeviceIndex(namespace, 0) + settings.saveCycleNeedsAnotherPass(namespace, false) + settings.saveCycleHadRejection(namespace, false) + settings.saveCycleFailure(namespace, null) + return ExecutionOutcome(Execution.COMPLETE) + } + // Room is first opened here, after the stale-work, endpoint, network-policy, token and identity gates. + val dao = WhoopDatabase.get(applicationContext).pushDao() + val progress = EndpointScopedProgressStore( + SharedPrefsPushProgressStore.from(applicationContext), + namespace, + ) + val startDeviceIndex = settings.nextDeviceIndex(namespace) + val run = PushCoordinator( + source = dao, + transport = transport, + progress = progress, + sourceId = sourceId, + // The real clock and zone live HERE, at the one caller that wants them, rather than as + // defaults 35 test constructions could inherit without saying so (#1787). + today = { LocalDate.now() }, + zoneId = ZoneId.systemDefault(), + destinationStillCurrent = { settings.enabledEndpoint() == endpoint }, + ).pushKnownDevices(startDeviceIndex, MAX_DEVICES_PER_RUN, capabilities) + settings.recordAcceptedBatches( + run.acceptedBatches, + records = run.acceptedRecords.toLong(), + ) + if (run.hasRetryableFailure) { + // Do not rotate away from a failing device: this WorkRequest retries the exact same + // device with its bounded runAttemptCount. Already-acked tables remain idempotent. + return ExecutionOutcome( + Execution.RETRY_FAILURE, + run.failure ?: PushFailure(PushFailureCode.NETWORK_IO), + ) + } + settings.saveNextDeviceIndex( + namespace, + persistedDeviceIndex(startDeviceIndex, run.nextDeviceIndex, retryableFailure = false), + ) + val cycleNeedsAnotherPass = settings.cycleNeedsAnotherPass(namespace) || run.hasMoreAppendRows + val runHadTerminalRejection = run.rejectedBatches > 0 && !run.hasRetryableFailure + val cycleHadRejection = settings.cycleHadRejection(namespace) || runHadTerminalRejection + val cycleFailure = settings.cycleFailure(namespace) ?: run.failure.takeIf { runHadTerminalRejection } + val cycleCompleted = run.nextDeviceIndex == 0 + settings.saveCycleNeedsAnotherPass(namespace, if (cycleCompleted) false else cycleNeedsAnotherPass) + // If append pagination starts another cycle, carry any terminal rejection through that cycle; + // otherwise a rejected table alongside a full append page could later be reported as success. + settings.saveCycleHadRejection( + namespace, + if (cycleCompleted && !cycleNeedsAnotherPass) false else cycleHadRejection, + ) + settings.saveCycleFailure( + namespace, + if (cycleCompleted && !cycleNeedsAnotherPass) null else cycleFailure, + ) + + return when { + !cycleCompleted || cycleNeedsAnotherPass -> { + ExecutionOutcome(Execution.CONTINUE) + } + cycleHadRejection -> { + ExecutionOutcome( + Execution.TERMINAL_FAILURE, + cycleFailure ?: PushFailure(PushFailureCode.HTTP_PROTOCOL_REJECTED), + ) + } + else -> { + ExecutionOutcome(Execution.COMPLETE) + } + } + } + + private fun retryOrStop(message: String): Decision = + if (!shouldRetryPush(runAttemptCount)) { + Decision( + Result.failure(), false, status = Status.FAILED, + message = applicationContext.getString(R.string.push_error_paused, message), + ) + } else { + Decision( + Result.retry(), true, status = Status.RETRYING, + message = applicationContext.getString(R.string.push_error_retrying, message), + ) + } + + private fun failureMessage(failure: PushFailure?): String = pushFailureMessage( + applicationContext, + failure ?: PushFailure(PushFailureCode.NETWORK_IO), + ) + + private fun recordSettledStatus( + settings: SelfHostedPushSettings, + decision: Decision, + pending: Boolean, + ) { + when { + pending && !decision.willRetry -> settings.recordContinuation() + decision.status == Status.SUCCESS -> settings.recordSuccess() + decision.status == Status.CONTINUING -> settings.recordContinuation() + decision.status == Status.RETRYING -> settings.recordRetrying(decision.message.orEmpty()) + decision.status == Status.FAILED -> settings.recordError(decision.message.orEmpty()) + else -> Unit + } + } + + private companion object { + const val MAX_DEVICES_PER_RUN = 1 + } +} diff --git a/android/app/src/main/java/com/noop/push/SharedPrefsPushProgressStore.kt b/android/app/src/main/java/com/noop/push/SharedPrefsPushProgressStore.kt new file mode 100644 index 0000000000..8f0d0c81e9 --- /dev/null +++ b/android/app/src/main/java/com/noop/push/SharedPrefsPushProgressStore.kt @@ -0,0 +1,100 @@ +package com.noop.push + +import android.content.Context +import android.content.SharedPreferences +import com.noop.data.SecurePrefs +import java.security.MessageDigest + +/** Encrypted durable progress plus remembered device scopes; never endpoint URLs or bearer tokens. */ +class SharedPrefsPushProgressStore private constructor( + private val prefs: SharedPreferences, +) : PushProgressStore { + override suspend fun knownDeviceIds(): Set = prefs.getStringSet(KEY_DEVICES, emptySet()).orEmpty() + + override suspend fun rememberDeviceId(deviceId: String) { + val updated = knownDeviceIds() + deviceId + check(prefs.edit().putStringSet(KEY_DEVICES, updated).commit()) { "Could not persist push device scope" } + } + + override suspend fun cursor(table: PushAppendTable, deviceId: String): PushCursor? { + val prefix = key("append", table.wireName, deviceId) + val rowId = prefs.getLong("$prefix.row", 0L) + val fingerprint = prefs.getString("$prefix.key", null) + return if (rowId > 0 && fingerprint != null) PushCursor(rowId, fingerprint) else null + } + + override suspend fun saveCursor(table: PushAppendTable, deviceId: String, cursor: PushCursor) { + val prefix = key("append", table.wireName, deviceId) + check(prefs.edit().putLong("$prefix.row", cursor.rowId) + .putString("$prefix.key", cursor.naturalKeyFingerprint).commit()) { + "Could not persist push cursor" + } + } + + override suspend fun window(table: PushMutableTable, deviceId: String): PushWindowProgress? { + val prefix = key("window", table.wireName, deviceId) + val batch = prefs.getString("$prefix.batch", null) ?: return null + val from = prefs.getString("$prefix.from", null) ?: return null + val to = prefs.getString("$prefix.to", null) ?: return null + return PushWindowProgress( + PushWindow( + fromDay = from, + toDay = to, + startTsInclusive = prefs.getLong("$prefix.start", 0L), + endTsExclusive = prefs.getLong("$prefix.end", 0L), + ), + batch, + parseDayHashes(prefs.getString("$prefix.dayHashes", null)), + ) + } + + override suspend fun saveWindow(table: PushMutableTable, deviceId: String, progress: PushWindowProgress) { + val prefix = key("window", table.wireName, deviceId) + check(prefs.edit() + .putString("$prefix.batch", progress.batchId) + .putString("$prefix.from", progress.window.fromDay) + .putString("$prefix.to", progress.window.toDay) + .putLong("$prefix.start", progress.window.startTsInclusive) + .putLong("$prefix.end", progress.window.endTsExclusive) + .putString("$prefix.dayHashes", encodeDayHashes(progress.dayHashes)) + .commit()) { "Could not persist push window" } + } + + private fun key(kind: String, table: String, deviceId: String): String = + "$kind.$table.${sha256(deviceId)}" + + private fun encodeDayHashes(hashes: Map): String { + check(hashes.all { (day, hash) -> + runCatching { java.time.LocalDate.parse(day) }.isSuccess && hash.matches(HASH_PATTERN) + }) { "Invalid mutable day hash progress" } + return hashes.toSortedMap().entries.joinToString(",") { (day, hash) -> "$day:$hash" } + } + + private fun parseDayHashes(encoded: String?): Map { + if (encoded.isNullOrEmpty()) return emptyMap() + return runCatching { + encoded.split(',').associate { item -> + val separator = item.indexOf(':') + require(separator > 0) + val day = item.substring(0, separator) + val hash = item.substring(separator + 1) + java.time.LocalDate.parse(day) + require(hash.matches(HASH_PATTERN)) + day to hash + } + }.getOrDefault(emptyMap()) + } + + companion object { + private const val PREFS = "self_hosted_push_progress" + private const val KEY_DEVICES = "known_devices" + private val HASH_PATTERN = Regex("[0-9a-f]{64}") + + fun from(context: Context) = SharedPrefsPushProgressStore( + SecurePrefs.of(context.applicationContext, PREFS), + ) + + internal fun sha256(value: String): String = MessageDigest.getInstance("SHA-256") + .digest(value.toByteArray()).joinToString("") { "%02x".format(it) } + } +} diff --git a/android/app/src/main/java/com/noop/testcentre/AndroidDiagnostics.kt b/android/app/src/main/java/com/noop/testcentre/AndroidDiagnostics.kt index 688434ffcb..3035e2f82e 100644 --- a/android/app/src/main/java/com/noop/testcentre/AndroidDiagnostics.kt +++ b/android/app/src/main/java/com/noop/testcentre/AndroidDiagnostics.kt @@ -54,6 +54,50 @@ object AndroidDiagnostics { ?.let { runCatching { com.noop.ble.WhoopModel.valueOf(it) }.getOrNull() } } + /** + * The history-offload write-health line. + * + * "Offload:", not "Data write:". The stamp behind it is written ONLY when a backfill session + * persists rows, so it says nothing about live streaming - and on a strap that offloads nothing but + * streams happily (an unbonded 5/MG, the designed end state of #1635) the old label read as "this + * app has stored nothing from your strap" while a hundred thousand HR rows sat under that very + * device id. The scope now lives in the label, and the zero case says outright what it excludes. + * + * Pure and extracted for the same reason [strapProvidesLine] is: [summaryLines] needs a real + * Context, and the wording is the part a change would silently break. + */ + internal fun offloadLine(okAtSec: Long, ageMs: Long): String = + "Offload: " + if (okAtSec > 0L) { + "rows last landed ${relTime(ageMs)}" + } else { + "no history rows ever persisted (live HR/R-R are not counted here)" + } + + /** + * What the active strap actually delivered over the window — the line that says which scores can + * exist at all. + * + * A 5/MG that never completes its handshake streams live HR and R-R over the standard characteristic + * and nothing else: motion and steps arrive only with the proprietary offload. Without motion the + * sleep stager has no HR-only fallback and the workout detector returns before it looks at heart rate, + * so Rest reads "No data" and no bout is ever found — and until now a report showed those absences + * with nothing connecting them to their single cause. A reader had to know the pipeline to infer it. + * + * The window is IN the label. Without it "Provides: motion NO" reads as a capability claim, and a + * strap simply not worn for two days would be reported as incapable of motion — the opposite kind of + * wrong from the one this line exists to prevent. Over a window of actual wear, delivered and capable + * are the same thing; the label keeps that assumption visible instead of implied. + * The label is padded to 13 like every other in this block ("Model:", "Offload:"), and the window + * rides the VALUE. "Provides(48h):" is 15 and overhung the column in a report that is aligned by hand + * and read by eye. + * Pure so it is unit-tested directly; byte-identical to the Swift twin. + */ + internal fun strapProvidesLine(hr: Boolean, rr: Boolean, motion: Boolean, steps: Boolean): String { + fun mark(b: Boolean) = if (b) "yes" else "NO" + return "Provides: HR ${mark(hr)} · R-R ${mark(rr)} · motion ${mark(motion)} · steps ${mark(steps)}" + + " (last 48h)" + } + /** * Strap identity + data-state lines for the debug export. Offline-safe: reads persisted prefs and the * canonical "my-whoop" daily spine, so it works from the scheduled background export too. Model, @@ -88,22 +132,86 @@ object AndroidDiagnostics { pairedCount = fwRows.size, ) add("Firmware: ${fw ?: "unknown (connect to record)"}") - val syncSec = com.noop.ui.NoopPrefs.lastSyncAt(context) - add("Last sync: ${if (syncSec > 0L) relTime(System.currentTimeMillis() - syncSec * 1000L) else "never"}") + // THIS strap's own sync, attributed the same way the firmware above is. The old global key + // reported whichever strap synced last, so a 5/MG that had never banked a row showed its + // paired 4.0's timestamp — the line that made a non-existent sync regression look real. + val syncSec = com.noop.ble.resolveLastSync( + perDevice = com.noop.ui.NoopPrefs.lastSyncAtFor(context, fwRow?.peripheralId), + legacyGlobal = com.noop.ui.NoopPrefs.lastSyncAt(context), + pairedCount = fwRows.size, + ) ?: 0L + add("Last sync: ${if (syncSec > 0L) relTime(System.currentTimeMillis() - syncSec * 1000L) + else "never (this strap)"}") // #57: write-health. "Last sync" fires even on an empty/failed offload, so distinguish "rows // actually landed" from "an offload STALLED on a persist failure" (history won't persist — // usually a backup restored without an app restart, the closed-DB class). val p = com.noop.ui.NoopPrefs.of(context) - val okAt = p.getLong("sync.lastWriteOkAt", 0L) - val stalledAt = p.getLong("sync.lastWriteStalledAt", 0L) + // Attributed exactly like "Last sync" above — the legacy global counts only when a single + // strap could have written it, so the pair can never mix one strap's stall with another's + // success. Read together: "stalled more recently than ok" is the alarm. + val okAt = com.noop.ble.resolveLastSync( + perDevice = com.noop.ble.writeHealthPrefKey(fwRow?.peripheralId, "lastWriteOkAt") + ?.let { p.getLong(it, 0L) } ?: 0L, + legacyGlobal = p.getLong("sync.lastWriteOkAt", 0L), + pairedCount = fwRows.size, + ) ?: 0L + val stalledAt = com.noop.ble.resolveLastSync( + perDevice = com.noop.ble.writeHealthPrefKey(fwRow?.peripheralId, "lastWriteStalledAt") + ?.let { p.getLong(it, 0L) } ?: 0L, + legacyGlobal = p.getLong("sync.lastWriteStalledAt", 0L), + pairedCount = fwRows.size, + ) ?: 0L val restoreAt = p.getLong("backup.lastRestoreAt", 0L) val now = System.currentTimeMillis() - add("Data write: ${if (okAt > 0L) "rows last landed ${relTime(now - okAt * 1000L)}" else "no rows ever persisted"}") + // "Offload:", not "Data write:". The stamp behind it is written ONLY when a backfill session + // persists rows, so it says nothing about live streaming - and on a strap that offloads + // nothing but streams happily (an unbonded 5/MG, the designed end state of #1635) the old + // label read as "this app has stored nothing from your strap" while a hundred thousand HR + // rows sat under that very device id. The scope now lives in the label, and the zero case + // says outright what it does not cover. + // Age only means anything when something landed; pass 0 otherwise rather than `now`, which + // would be a 56-year "age" sitting unused in an argument a reader has to check is unused. + add(offloadLine(okAt, if (okAt > 0L) now - okAt * 1000L else 0L)) if (stalledAt > 0L && stalledAt >= okAt) { add(" ⚠ history NOT persisting — last offload STALLED ${relTime(now - stalledAt * 1000L)} " + "(if you restored a backup, fully restart the app — #57)") } if (restoreAt > 0L) add("Last restore: ${relTime(now - restoreAt * 1000L)}") + // What the home-screen widgets actually cost. Reported unconditionally, including the "no + // pushes" case, because the absence of widget activity is itself the answer to a drain + // report — and until this line existed an export could not distinguish the two. + add(com.noop.widget.WidgetTelemetry.snapshot(now).render()) + // #1770 follow-up: which streams the ACTIVE strap actually delivered over the last 48 h. Four + // EXISTS seeks, not counts — see WhoopDao.streamPresence for why that distinction matters on a + // table holding ~190k motion rows a night. + runCatching { + // Same resolution funnelLines uses, blank guard included: the registry's ACTIVE id, not + // the canonical label, so a two-strap install reports the strap actually being worn. A + // BLANK id must fall back rather than be queried — it matches no rows, so every stream + // would read NO and the line would report a working strap as providing nothing, which is + // the exact misreading it exists to prevent. + val activeId = runCatching { + (context.applicationContext as? com.noop.NoopApplication)?.deviceRegistry?.activeDeviceId() + }.getOrNull()?.takeIf { it.isNotBlank() } ?: "my-whoop" + val nowSec = now / 1000L + val present = com.noop.data.WhoopRepository.from(context) + .streamPresence(activeId, nowSec - 48L * 3600L, nowSec) + add(strapProvidesLine(present.hr, present.rr, present.gravity, present.steps)) + } + // #1735: row COUNTS alone cannot separate "Health Connect never brought the ride in" from + // "it did, but nothing has re-scored since". Both halves of that need a WHEN, and neither had + // one: the importer recorded no run time at all, and the engine's "re-score: done" goes only to + // the live log, so an export written hours later has already rolled it away. + val hcAt = p.getLong("hc.lastImportOkAt", 0L) + add( + hcImportLine( + ago = if (hcAt > 0L) relTime(now - hcAt * 1000L) else null, + rows = p.getInt("hc.lastImportRows", 0), + throughDay = p.getString("hc.lastImportThroughDay", null), + ), + ) + val scoredAt = p.getLong("score.lastPassAt", 0L) + add(scoringPassLine(ago = if (scoredAt > 0L) relTime(now - scoredAt * 1000L) else null)) add("Timezone: ${tzLine()}") val repo = com.noop.data.WhoopRepository.from(context) val days = repo.days("my-whoop") @@ -178,9 +286,9 @@ object AndroidDiagnostics { if (sk.isNotEmpty()) { session = s; skin = sk; break } } } - val grav = repo.gravitySamples(id, session.startTs, session.endTs, Int.MAX_VALUE) - val hr = repo.hrSamples(id, session.startTs, session.endTs, Int.MAX_VALUE) - val rr = repo.rrIntervals(id, session.startTs, session.endTs, Int.MAX_VALUE) + val grav = repo.gravitySamplesForDevice(id, session.startTs, session.endTs, Int.MAX_VALUE) + val hr = repo.hrSamplesForDevice(id, session.startTs, session.endTs, Int.MAX_VALUE) + val rr = repo.rrIntervalsForDevice(id, session.startTs, session.endTs, Int.MAX_VALUE) val resp = repo.respSamples(id, session.startTs, session.endTs, Int.MAX_VALUE) add("Night ${dayStamp(session.startTs)}: grav=${grav.size} hr=${hr.size} rr=${rr.size} resp=${resp.size} skin=${skin.size}") if (grav.isEmpty() && hr.isEmpty()) { @@ -196,7 +304,15 @@ object AndroidDiagnostics { repo.rawSampleCountsByDevice(session.startTs, session.endTs) .filter { it.first != id } }.getOrDefault(emptyList()) - add(orphanedSamplesLine(id, elsewhere)) + // The registry, so a SECOND strap's night is not reported as a read failure. Only queried + // when the active id came back empty, so a healthy install still pays nothing. + val otherStraps = runCatching { + repo.pairedDevices() + .filter { it.status != "archived" && it.id != id } + .map { it.id } + .toSet() + }.getOrDefault(emptySet()) + add(orphanedSamplesLine(id, elsewhere, otherStraps)) return@runCatching } com.noop.analytics.SleepStager.remFunnelDiagnostic(session.startTs, session.endTs, grav, hr, rr, resp) @@ -276,6 +392,29 @@ object AndroidDiagnostics { add("Stored: " + perSource.joinToString(" ") { "${it.first}=${it.second.size}" }) val latest = perSource.flatMap { it.second }.maxByOrNull { it.startTs } add(if (latest != null) "Latest: ${dayStamp(latest.startTs)} · ${latest.sport} (${latest.source})" else "Latest: none") + // #1735: "auto-detect is off but workouts keep appearing" is answerable only if the log says + // which of the TWO detectors is meant. The Settings toggle governs the opt-in suggestion card + // ONLY; the engine derives durable sport="detected" rows on every pass regardless, and all the + // per-bout tracing for that sits behind the Test Centre WORKOUTS domain, which a reporter + // filing a non-test-mode bug will not have on. Counts read from the store, so this states what + // IS, not what the code intends. + // Guarded SEPARATELY from the section: this file's contract is that every probe is guarded, + // and a throw in here would otherwise be caught by the outer handler and reported as + // "(workout sources unavailable)" - blaming the store for a failure in the auto-detect probe, + // with the sources sitting right above it having plainly worked. + runCatching { + autoDetectStateLine( + suggestionCardEnabled = com.noop.ui.NoopPrefs.autoDetectWorkouts(context), + storedDetectedRows = perSource.flatMap { it.second }.count { it.sport == "detected" }, + // Summed over the SAME strap ids whose rows were counted above. A two-strap install + // banks detected rows under both "-noop" and "my-whoop-noop", so reading + // dismissals for the active strap alone could print "detected=52 dismissed=0" while + // the dismissals sat under the other id, sending a reader after the #107 mechanism. + dismissedMarkers = runCatching { + listOf(active, "my-whoop").distinct().sumOf { repo.dismissedDetected(it).size } + }.getOrNull(), + ) + }.onSuccess { add(it) }.onFailure { add("(auto-detect state unavailable: ${it.message})") } }.onFailure { add("(workout sources unavailable: ${it.message})") } } @@ -360,7 +499,9 @@ object AndroidDiagnostics { add(when { behind > 3 * 86400L -> "Strap clock: ${behind / 86400L}d behind wall (reset/stale — alarm unreliable)" behind < -3 * 86400L -> "Strap clock: ${-behind / 86400L}d AHEAD of wall (future-dated — alarm unreliable)" - else -> "Strap clock: OK" + // #1706: say what this is measuring. It reads RECORD timestamps, and sat two lines + // above an alarm readback claiming 2045, which reads as the two contradicting. + else -> "Strap clock: OK (from record timestamps, not the alarm readback)" }) } val sent = p.getLong("alarm.lastArmSentEpoch", 0L) @@ -375,9 +516,20 @@ object AndroidDiagnostics { add(line) val reported = p.getLong("alarm.lastReportedEpoch", 0L) if (reported > 0L) { - val mismatch = kotlin.math.abs(reported - sent) > 120 - add("Strap reports: ${alarmStamp(reported)}" + - if (mismatch) " ⚠️ MISMATCH — strap didn't accept the time" else " ✓ matches") + // #1706: only judge when both halves are known to be the SAME strap. The readback is + // written on the WHOOP 4.0 path alone, so a 5.0-active install comparing these was + // always comparing two devices and then blaming one of them. + val verdict = com.noop.analytics.AlarmReadback.verdict( + sentEpoch = sent, + reportedEpoch = reported, + sentDeviceId = p.getString("alarm.lastArmDeviceId", null), + reportedDeviceId = p.getString("alarm.lastReportedDeviceId", null), + ) + add("Strap reports: ${alarmStamp(reported)}" + com.noop.analytics.AlarmReadback.suffix(verdict)) + // The bytes the epoch was decoded from: what tells a stored stale alarm from a misdecode. + p.getString("alarm.lastReportedRaw", null) + ?.takeIf { it.isNotBlank() } + ?.let { add("Readback frame: $it") } } else add("Strap reports: (no readback)") } else add("Last arm: never") // #1: did the strap actually fire? (STRAP_DRIVEN_ALARM_EXECUTED) @@ -395,9 +547,130 @@ object AndroidDiagnostics { suspend fun dynamicLines(context: Context): List = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Default) { strapAndDataLines(context) + funnelLines(context) + workoutSourceLines(context) + - dailyDataLines(context) + alarmLines(context) + dailyDataLines(context) + alarmLines(context) + circadianLines(context) } + /** + * One-decimal float, always with a DOT. + * + * This file already pins `Locale.US` for its dates, for the same reason: the strap log is pasted into + * issues and read by whoever picks them up, so a device in a comma-decimal locale must not emit + * "4,7 bpm" beside US-formatted timestamps. Kotlin's `"%.1f".format(x)` uses the default locale and + * would do exactly that. + */ + private fun fmt1(v: Double): String = String.format(java.util.Locale.US, "%.1f", v) + + /** Hourly HR buckets over the 14-day window below which the profile is not pooled at all. Mirrors + * `circadianBinsFrom`'s own floor — kept here so the diagnostic names the real number, not a guess. */ + internal const val CIRCADIAN_MIN_BUCKETS = 24 + /** Distinct populated local hours below which no estimate is attempted. + * + * REFERENCES the engine's own constant rather than repeating its value. A diagnostic that prints + * "needs 6" while the code requires 8 would send whoever reads the log after the wrong thing — + * the one failure mode this section exists to prevent. */ + internal val CIRCADIAN_MIN_HOURS get() = com.noop.analytics.V5HealthSignals.MIN_CIRCADIAN_BINS + + /** + * Which gate the body clock is failing. + * + * The first two are sequential gates and are reported in the order they run. The last two are a + * SINGLE condition in the engine (`daysObserved < minDaysForFit || relativeAmplitude < + * minRelativeAmplitude`), split apart here because "unreadable" on its own does not say whether to + * keep wearing the strap or to question the threshold — and those lead somewhere different. + * + * The card vanishes for two DIFFERENT reasons that look identical on screen: below the bucket floor + * or the hour floor there is NO estimate at all (`V5HealthSignals` returns null and the card's `?.let` + * skips it), while a thin or flat fit still returns an estimate marked UNREADABLE, which DOES draw a + * card with its own copy. Without this line those are indistinguishable from the outside — the report + * that prompted it had a body clock missing on both the Health card and the Sleep dial with no way to + * say which floor was short. + * + * Pure so the wording is assertable without a store, matching `noCaptureMsgText`. + */ + internal fun circadianVerdict( + buckets: Int, + populatedHours: Int, + daysObserved: Int, + relativeAmplitude: Double?, + amplitudeBpm: Double? = null, + ): String = when { + buckets < CIRCADIAN_MIN_BUCKETS -> + "no estimate — $buckets hourly HR buckets in 14d, needs $CIRCADIAN_MIN_BUCKETS" + populatedHours < CIRCADIAN_MIN_HOURS -> + "no estimate — HR lands in $populatedHours of 24 local hours, needs $CIRCADIAN_MIN_HOURS" + // Mirrors the engine's OR: a swing passes on the proportional test or on the absolute one. Read + // the two apart and this line starts reporting "too flat" for rhythms the engine accepts. + relativeAmplitude != null && + relativeAmplitude < com.noop.analytics.CircadianEngine.minRelativeAmplitude && + (amplitudeBpm ?: 0.0) < com.noop.analytics.CircadianEngine.minAbsoluteAmplitudeBpm -> + "unreadable — rhythm too flat (amplitude ${fmt1(relativeAmplitude * 100)}% of mesor, " + + "needs ${fmt1(com.noop.analytics.CircadianEngine.minRelativeAmplitude * 100)}% or " + + "${fmt1(com.noop.analytics.CircadianEngine.minAbsoluteAmplitudeBpm)} bpm)" + daysObserved < com.noop.analytics.CircadianEngine.minDaysForFit -> + "unreadable — $daysObserved days observed, needs " + + "${com.noop.analytics.CircadianEngine.minDaysForFit}" + // SOLID needs BOTH axes, exactly as estimatePhase assigns it. Reading only the days here printed + // "solid" for a wearer the engine had marked WIDE — the diagnostic contradicting the very screen + // it exists to explain, which is the one thing this section must never do. + daysObserved < com.noop.analytics.CircadianEngine.goodDaysForFit -> + "wide — $daysObserved days observed" + relativeAmplitude != null && + relativeAmplitude < com.noop.analytics.CircadianEngine.minRelativeAmplitude -> + "wide — $daysObserved days observed, but the swing clears only the absolute floor " + + "(${fmt1(relativeAmplitude * 100)}% of mesor)" + else -> "solid — $daysObserved days observed" + } + + /** + * The body clock's three inputs, RECOMPUTED from the store. + * + * It reports what `estimatePhase` WOULD return for the data on disk — not what `_v5Signals.bodyClock` + * is currently holding. Those can diverge: a cached-bins staleness once nulled the live estimate on a + * device whose store was fine, and against that failure this section would have printed a confident + * verdict beside a missing card, misleading exactly as the silence it replaced did. So a verdict here + * that disagrees with the screen is itself the finding — it means the snapshot, not the data, is wrong. + * + * Hours and days are counted from the RAW buckets rather than from `circadianBinsFrom`, which + * short-circuits to empty below the bucket floor — reporting its zeros would hide the very numbers + * this line exists to show. + */ + suspend fun circadianLines(context: Context): List = buildList { + add("─".repeat(40)) + add("Body clock inputs (recomputed from the store, not the live snapshot)") + runCatching { + val repo = com.noop.data.WhoopRepository.from(context) + val active = runCatching { + (context.applicationContext as com.noop.NoopApplication).activeDeviceId + }.getOrNull() ?: "unknown" + val nowMs = System.currentTimeMillis() + val now = nowMs / 1000L + val tz = java.util.TimeZone.getDefault().getOffset(nowMs) / 1000L + val buckets = repo.hrBucketsUnion(active, now - 14L * 86_400L, now, 3_600L) + val hours = buckets.map { (((it.bucket + tz) % 86_400L + 86_400L) % 86_400L / 3_600L) } + .distinct().size + val days = buckets.map { (it.bucket + tz) / 86_400L }.distinct().size + val bins = com.noop.ui.circadianBinsFrom(buckets, tz).first + val fit = com.noop.analytics.CircadianEngine.cosinor(bins) + val amp = fit?.let { f -> + if (f.mesor != 0.0) f.amplitude / kotlin.math.abs(f.mesor) else 0.0 + } + add("Source: $active (+ imported) window: last 14d") + add("Buckets: ${buckets.size} (floor $CIRCADIAN_MIN_BUCKETS) " + + "hours: $hours/24 (need $CIRCADIAN_MIN_HOURS) days: $days " + + "(need ${com.noop.analytics.CircadianEngine.minDaysForFit})") + // The ratio alone is not interpretable — "7.3% of mesor" says nothing about whether the + // rhythm is genuinely flat or the bar is simply set high for this wearer. The absolute pair is + // what makes the threshold arguable, and it is what `minRelativeAmplitude`'s own note asks for + // ("it needs a wearer whose amplitude is disproportionately small for their mesor"). + if (fit != null) { + add("Rhythm: amplitude ${fmt1(fit.amplitude)} bpm on a " + + "${fmt1(fit.mesor)} bpm mesor (acrophase " + + "${fmt1(fit.acrophaseHours)}h)") + } + add("Verdict: " + circadianVerdict(buckets.size, hours, days, amp, fit?.amplitude)) + }.onFailure { add(" (unavailable: ${it.message})") } + } + /** "3h 12m ago" style relative stamp for a positive age in ms. */ private fun relTime(deltaMs: Long): String { if (deltaMs < 60_000L) return "just now" @@ -431,6 +704,74 @@ object AndroidDiagnostics { } }.getOrDefault("unknown") + /** + * When Health Connect last completed an import, and what it brought. + * + * The export already listed per-source row counts, which answer "is there data" but never "is it + * MOVING". #1735 turns on exactly that difference: a ride that has not appeared is either one Health + * Connect never imported or one it imported and nothing re-scored, and a static count cannot tell + * those apart. [rows] of 0 with a recent [ago] is a real and useful state - the import ran and found + * nothing - which is why the empty path is stamped too. + * + * A null [ago] means no import has ever completed on this install. Stated plainly rather than dressed + * up as a fault: plenty of installs never connect Health Connect at all. + */ + internal fun hcImportLine(ago: String?, rows: Int, throughDay: String?): String { + if (ago == null) return "HC import: never completed on this install" + val through = throughDay?.let { " · through $it" } ?: "" + return "HC import: $rows row(s) $ago$through" + } + + /** + * When the scoring engine last completed a pass. + * + * The analyze watermark records WHAT was scored (an HR fingerprint) and never WHEN, and the engine's + * own "re-score: done" line is live-log only, so an export written hours after the pass has already + * rolled it away. Without this, "I synced and nothing appeared" cannot distinguish a pass that ran and + * found nothing new from one that never ran - and those need opposite next steps. + * + * Stamped at BOTH completion sites (the idle pass and the post-sync pass), so the freshest of the two + * is what shows. + */ + internal fun scoringPassLine(ago: String?): String = + if (ago == null) "Scoring: no pass has completed on this install" + else "Scoring: last pass $ago" + + /** + * Which workout detector produced what, and whether the Settings toggle has anything to do with it. + * + * NOOP has TWO detectors and they are deliberately separate (see AutoWorkoutDetector's header). The + * Settings toggle governs the opt-in SUGGESTION card, which only ever offers a workout and saves + * nothing until the user taps Save. The IntelligenceEngine separately derives durable sport="detected" + * rows from the 1 Hz store on every scoring pass, and that has never been gated by the toggle. + * + * Both are called "detect" in the UI, so #1735 read the second one's rows as the first one ignoring + * its own switch, which is an entirely reasonable reading. Every per-bout line that would have shown + * the difference sits behind the Test Centre WORKOUTS domain, and that report was filed as "not a + * test-mode bug" with the domain off, so the log carried nothing about it at all. + * + * Reads COUNTS from the store rather than describing intent: it states what is on disk, not what the + * code believes it does. The reassurance clause is emitted only for the combination that actually + * misleads (card off, rows present) so it never claims to explain a state it is not looking at. + * [dismissedMarkers] is null when the query failed, and renders "n/a" rather than a wrong zero, which + * would read as "your dismissals are not sticking". + */ + internal fun autoDetectStateLine( + suggestionCardEnabled: Boolean, + storedDetectedRows: Int, + dismissedMarkers: Int?, + ): String { + val card = if (suggestionCardEnabled) "on" else "off" + val dismissed = dismissedMarkers?.toString() ?: "n/a" + val note = if (!suggestionCardEnabled && storedDetectedRows > 0) { + " (rows with the card off are EXPECTED: a different detector makes them)" + } else { + "" + } + return "Auto-detect: suggestion card=$card · engine \"Activity\" rows=always on, not gated by " + + "that toggle · stored detected=$storedDetectedRows · dismissed markers=$dismissed$note" + } + /** A coarse OEM-kill heuristic by manufacturer (the aggressive-background-kill vendors). Pure and * internal so it unit-tests without a Context (the suite stays Robolectric-free). */ internal fun oemKillHeuristic(manufacturer: String): String = @@ -478,13 +819,42 @@ object AndroidDiagnostics { * the same window. Empty means the samples genuinely are not there and the fresh-re-add wording is * right; non-empty names the id that has them so the split is visible rather than inferred. * + * [otherLiveStrapIds] is the registered, non-archived device ids OTHER than the active one. It exists + * because the "not being read" wording was itself an over-assertion — the mirror image of the one it + * replaced. A wearer with TWO straps has nights owned by the other one, and [DayOwnerResolver] hands + * each day to whichever device actually holds its data. Samples under another id are then completely + * normal, and calling that a read failure sends the reader hunting a bug that is not there (it sent + * ME hunting one). Only when the id holding the samples is NOT a live registered strap is the #1193 + * split the remaining explanation. + * + * That correction then over-corrected. "So this is expected" assumes a night is worn on ONE strap, + * and a reporter wearing a 4.0 and a 5.0 together hit the case it denies: the active strap banked + * nothing because its handshake never completed (#1635), while the other strap's rows made the line + * declare the silence normal. Nothing available here can tell the two apart — the wearer knows which + * straps were on the wrist and this function cannot — so it states the fork instead of picking a + * side, and names the sync as what to check in the half where something IS wrong. + * * Pure so the wording is unit-tested without a database, a strap, or a registry. */ - internal fun orphanedSamplesLine(activeId: String, othersWithSamples: List>): String { + internal fun orphanedSamplesLine( + activeId: String, + othersWithSamples: List>, + otherLiveStrapIds: Set = emptySet(), + ): String { if (othersWithSamples.isEmpty()) { return "(no raw biometric samples under '$activeId' for this night — expected on a freshly " + "re-added strap; reconnect + let a history sync run, then re-export)" } + val ownedByAnotherStrap = othersWithSamples.filter { it.first in otherLiveStrapIds } + if (ownedByAnotherStrap.isNotEmpty()) { + val who = ownedByAnotherStrap + .sortedWith(compareByDescending> { it.second }.thenBy { it.first }) + .joinToString(", ") { "'${it.first}' (${it.second} rows)" } + return "(no raw biometric samples under the ACTIVE id '$activeId' for this night — they are " + + "under $who, another registered strap. If you wore THAT strap this night, this is expected " + + "and the dayOwner line for this date names the owner. If you wore BOTH, the active strap " + + "banked nothing for this night and its sync is what to check, not this line.)" + } // Tie-break on id: Kotlin's sortedByDescending is stable but Swift's `sorted` is NOT, so equal // counts could otherwise order differently on the two platforms and the twin lines would diverge. // The tie-break itself compares UTF-16 code units here and Unicode canonical order in Swift; those diff --git a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt new file mode 100644 index 0000000000..1055a9bc17 --- /dev/null +++ b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt @@ -0,0 +1,477 @@ +package com.noop.testcentre + +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.content.FileProvider +import com.noop.BuildConfig +import com.noop.data.WhoopRepository +import com.noop.ingest.RawSensorExport +import com.noop.protocol.Whoop5RawImu +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.util.UUID + +/** App-private, explicitly user-started bounded 5/MG raw-data capture with annotations. */ +class GroundTruthCollector private constructor(private val context: Context) { + data class Snapshot( + val active: Boolean, + val sessionId: String?, + val deviceId: String?, + val startedAtMs: Long, + val endedAtMs: Long, + val exported: Boolean, + ) + + data class SessionSummary( + val id: String, + val deviceId: String?, + val startedAtMs: Long, + val endedAtMs: Long?, + val capturedStartedAtMs: Long?, + val capturedEndedAtMs: Long?, + val comment: String, + val active: Boolean, + val exported: Boolean, + val lastExportedAtMs: Long?, + val markers: List, + ) + + data class Marker(val id: String, val atMs: Long, val type: String, val text: String) + data class CaptureStats( + val bytes: Long, + val coveredSeconds: Int, + val expectedSeconds: Int, + val startupSeconds: Int, + ) { + val missingSeconds get() = (expectedSeconds - coveredSeconds).coerceAtLeast(0) + val complete get() = expectedSeconds > 0 && missingSeconds == 0 + } + + private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + private val directory = File(context.filesDir, "ground-truth").apply { mkdirs() } + + fun captureStats(session: SessionSummary, nowMs: Long = System.currentTimeMillis()): CaptureStats { + val end = session.endedAtMs ?: nowMs + val baseFrom = ceilSecond(session.startedAtMs) + val to = end / 1_000L - 1L + val raw = ImuSessionFileStore(context).stats(session.id, baseFrom, to) + val first = raw.firstTs?.takeIf { it <= baseFrom + 1 } + val from = if (session.capturedStartedAtMs != null) maxOf(baseFrom, first ?: baseFrom) else baseFrom + val adjusted = if (from == baseFrom) raw.coveredSeconds else + ImuSessionFileStore(context).stats(session.id, from, to).coveredSeconds + return CaptureStats(raw.bytes, adjusted, (to - from + 1).coerceAtLeast(0).toInt(), + (from - baseFrom).coerceAtLeast(0).toInt()) + } + + @Synchronized + fun snapshot(): Snapshot = Snapshot( + active = prefs.getBoolean("active", false), + sessionId = prefs.getString("sessionId", null), + deviceId = prefs.getString("deviceId", null), + startedAtMs = prefs.getLong("startedAtMs", 0L), + endedAtMs = prefs.getLong("endedAtMs", 0L), + exported = prefs.getBoolean("exported", false), + ) + + @Synchronized + fun start(deviceId: String, nowMs: Long = System.currentTimeMillis()): Snapshot { + val id = nowMs.toString() + prefs.edit() + .putBoolean("active", true) + .putBoolean("exported", false) + .putString("sessionId", id) + .putString("deviceId", deviceId) + .putLong("startedAtMs", nowMs) + .putString(sessionDeviceKey(id), deviceId) + .putLong(sessionStartKey(id), nowMs) + .putBoolean(sessionExportedKey(id), false) + .putBoolean(sessionCaptureKey(id), true) + .apply() + append(id, event(nowMs, "start").put("strap_device_id", deviceId)) + ImuSessionFileStore(context).start(id, deviceId, nowMs) + return snapshot() + } + + @Synchronized + fun setComment(sessionId: String, comment: String) { + prefs.edit().putString(sessionCommentKey(sessionId), comment.take(4_000)).apply() + } + + @Synchronized + fun sessions(): List { + val current = snapshot() + return directory.listFiles { file -> file.name.startsWith("session-") && file.name.endsWith(".jsonl") } + .orEmpty() + .mapNotNull { file -> + val id = file.name.removePrefix("session-").removeSuffix(".jsonl") + val rows = runCatching { + file.useLines { lines -> + lines.filter { it.isNotBlank() } + .mapNotNull { line -> runCatching { JSONObject(line) }.getOrNull() } + .toList() + } + }.getOrNull() ?: return@mapNotNull null + val first = rows.firstOrNull() ?: return@mapNotNull null + val isCurrent = current.sessionId == id + val physicalStart = prefs.getLong(sessionStartKey(id), first.optLong("at_ms")) + val capturedEnd = rows.lastOrNull { it.optString("kind") == "stop" }?.optLong("at_ms") + val hasCapture = prefs.getBoolean(sessionCaptureKey(id), false) + SessionSummary( + id = id, + deviceId = prefs.getString(sessionDeviceKey(id), null) + ?: first.optString("strap_device_id").takeIf(String::isNotBlank) + ?: current.deviceId.takeIf { isCurrent }, + startedAtMs = prefs.getLong(sessionUsedStartKey(id), physicalStart), + endedAtMs = capturedEnd?.let { prefs.getLong(sessionUsedEndKey(id), it) }, + capturedStartedAtMs = physicalStart.takeIf { hasCapture }, + capturedEndedAtMs = capturedEnd.takeIf { hasCapture }, + comment = prefs.getString(sessionCommentKey(id), "").orEmpty(), + active = isCurrent && current.active, + exported = prefs.getBoolean(sessionExportedKey(id), isCurrent && current.exported), + lastExportedAtMs = prefs.getLong(sessionLastExportKey(id), 0L).takeIf { it > 0 }, + markers = rows.filter { it.optString("kind") == KIND_MARKER }.map { row -> + Marker(row.optString("marker_id"), row.optLong("at_ms"), + row.optString("marker_type", "moment"), row.optString("text")) + }.sortedBy(Marker::atMs), + ) + } + .sortedByDescending { it.startedAtMs } + } + + /** Delete one completed capture and all app-private files and metadata belonging to it. */ + @Synchronized + fun deleteSession(sessionId: String): Boolean { + val current = snapshot() + if (current.active && current.sessionId == sessionId) return false + if (sessions().none { it.id == sessionId }) return false + + val payloadFiles = listOf(File(context.cacheDir, "logs/noop-5mg-raw-$sessionId.zip")) + // Each step aborts on the first failure, so a delete is all-or-nothing from the user's side: + // whatever survives is still owned by a session that is still listed, never orphaned. That + // relies on deleteFiles() being idempotent - a missing directory reads as success - so the + // retry re-runs the earlier steps cleanly. + val imuStore = ImuSessionFileStore(context) + if (!imuStore.deleteFiles(sessionId)) return false + if (!payloadFiles.all { file -> !file.exists() || file.delete() }) return false + imuStore.remove(sessionId) + // The event file is also the durable session index. Delete it last so any earlier failure + // leaves the session visible and the explicit delete action retryable. + if (eventFile(sessionId).exists() && !eventFile(sessionId).delete()) return false + + prefs.edit() + .remove(sessionDeviceKey(sessionId)) + .remove(sessionStartKey(sessionId)) + .remove(sessionEndKey(sessionId)) + .remove(sessionCommentKey(sessionId)) + .remove(sessionExportedKey(sessionId)) + .remove(sessionLastExportKey(sessionId)) + .remove(sessionUsedStartKey(sessionId)) + .remove(sessionUsedEndKey(sessionId)) + .remove(sessionCaptureKey(sessionId)) + .apply { + if (current.sessionId == sessionId) clearCurrentSession() + } + .apply() + return true + } + + /** Trim the analysis/export interval without rewriting the physical capture provenance. */ + @Synchronized + fun setSessionRange(sessionId: String, fromMs: Long, toMs: Long): Boolean { + val session = sessions().firstOrNull { it.id == sessionId } ?: return false + if (session.active || fromMs <= 0 || fromMs > toMs || toMs - fromMs > MAX_RANGE_MS) return false + prefs.edit().putLong(sessionUsedStartKey(sessionId), fromMs) + .putLong(sessionUsedEndKey(sessionId), toMs).apply() + val deviceId = session.deviceId ?: return false + ImuSessionFileStore(context).register(sessionId, deviceId, fromMs, toMs) + return true + } + + @Synchronized + fun createHistoricalSession(deviceId: String, fromMs: Long, toMs: Long): SessionSummary? { + if (fromMs <= 0 || fromMs > toMs || toMs - fromMs > MAX_RANGE_MS) return null + val id = System.currentTimeMillis().toString() + prefs.edit().putString(sessionDeviceKey(id), deviceId).putLong(sessionStartKey(id), fromMs) + .putLong(sessionEndKey(id), toMs).putLong(sessionUsedStartKey(id), fromMs) + .putLong(sessionUsedEndKey(id), toMs).putBoolean(sessionCaptureKey(id), false).apply() + append(id, event(fromMs, "start").put("strap_device_id", deviceId)) + append(id, event(toMs, "stop")) + ImuSessionFileStore(context).register(id, deviceId, fromMs, toMs) + return sessions().firstOrNull { it.id == id } + } + + @Synchronized + fun addMarker(sessionId: String, atMs: Long, type: String, text: String): Marker? { + val session = sessions().firstOrNull { it.id == sessionId } ?: return null + val end = session.endedAtMs ?: System.currentTimeMillis() + val marker = Marker(UUID.randomUUID().toString(), atMs.coerceIn(session.startedAtMs, end), + type.take(40), text.take(500)) + append(sessionId, JSONObject().apply { + put("at_ms", marker.atMs); put("kind", KIND_MARKER); put("marker_id", marker.id) + put("marker_type", marker.type); put("text", marker.text) + }) + return marker + } + + @Synchronized + fun updateMarker(sessionId: String, marker: Marker): Boolean { + val session = sessions().firstOrNull { it.id == sessionId } ?: return false + val end = session.endedAtMs ?: System.currentTimeMillis() + return rewriteEvents(sessionId) { rows -> rows.map { row -> + if (row.optString("kind") == KIND_MARKER && row.optString("marker_id") == marker.id) + JSONObject(row.toString()).apply { + put("at_ms", marker.atMs.coerceIn(session.startedAtMs, end)) + put("marker_type", marker.type.take(40)); put("text", marker.text.take(500)) + } else row + }} + } + + @Synchronized + fun deleteMarker(sessionId: String, markerId: String): Boolean = rewriteEvents(sessionId) { rows -> + rows.filterNot { it.optString("kind") == KIND_MARKER && it.optString("marker_id") == markerId } + } + + private fun rewriteEvents(sessionId: String, transform: (List) -> List): Boolean { + val file = eventFile(sessionId); if (!file.isFile) return false + val rows = file.useLines { it.filter(String::isNotBlank).map(::JSONObject).toList() } + val content = transform(rows).joinToString("\n", postfix = "\n") { it.toString() } + val staged = File(file.parentFile, ".${file.name}.tmp") + return runCatching { + staged.writeText(content) + check(staged.renameTo(file)) { "Could not commit marker edit" } + }.onFailure { staged.delete() }.isSuccess + } + + /** Delete every completed capture. Active captures are deliberately retained. */ + @Synchronized + fun deleteAllSessions(): Int { + val completedIds = sessions().filterNot(SessionSummary::active).map(SessionSummary::id) + return completedIds.count(::deleteSession) + } + + @Synchronized + fun stop(nowMs: Long = System.currentTimeMillis()): Snapshot { + val before = snapshot() + if (!before.active || before.sessionId == null) return before + append(before.sessionId, event(nowMs, "stop")) + ImuSessionFileStore(context).complete(before.sessionId, nowMs) + prefs.edit().putBoolean("active", false).putLong("endedAtMs", nowMs) + .putLong(sessionEndKey(before.sessionId), nowMs) + .apply() + return snapshot() + } + + suspend fun export(repo: WhoopRepository, sessionId: String? = null): File = withContext(Dispatchers.IO) { + val snap = snapshot() + val id = sessionId ?: requireNotNull(snap.sessionId) { "No ground-truth session has been recorded" } + val summary = sessions().firstOrNull { it.id == id } ?: error("Ground-truth session is missing") + require(!summary.active) { "Stop the session before exporting" } + val deviceId = summary.deviceId + val source = eventFile(id) + require(source.isFile) { "Ground-truth event file is missing" } + val events = source.useLines { lines -> + lines.filter { it.isNotBlank() } + .mapNotNull { line -> runCatching { JSONObject(line) }.getOrNull() } + .toList() + } + val endMs = summary.endedAtMs ?: events.lastOrNull()?.optLong("at_ms") ?: summary.startedAtMs + // Export the complete bounded interval; markers are optional annotations. + val sensorFrom = ceilSecond(summary.startedAtMs) + val sensorTo = endMs / 1_000L - 1L + val imuSegments = if (deviceId == null || sensorFrom > sensorTo) emptyList() else + ImuSessionFileStore(context).exportSegments(id, sensorFrom, sensorTo) + val fullFrom = sensorFrom + val fullTo = endMs / 1_000L - 1L + // A live capture can only produce its first complete one-second frame after startup. + // Historical windows must still cover the exact requested start. + val firstImuTs = imuSegments.minOfOrNull { it.startTs }?.takeIf { it <= fullFrom + 1 } + val coverageFrom = if (summary.capturedStartedAtMs != null) maxOf(fullFrom, firstImuTs ?: fullFrom) + else fullFrom + val imuComplete = covers(imuSegments, coverageFrom, fullTo) + val outDir = File(context.cacheDir, "logs").apply { mkdirs() } + val zip = File(outDir, "noop-5mg-raw-$id.zip") + ZipOutputStream(zip.outputStream().buffered()).use { out -> + var v18Count: Int + out.putNextEntry(ZipEntry("v18-aux.csv")) + out.bufferedWriterNoClose().use { writer -> + v18Count = writeV18AuxCsv(writer, repo, deviceId.orEmpty(), sensorFrom, sensorTo) + writer.flush() + } + out.closeEntry() + + var rawCounts: Map + out.putNextEntry(ZipEntry("raw-sensors.csv")) + out.bufferedWriterNoClose().use { writer -> + rawCounts = RawSensorExport.writeCsv(writer, repo, deviceId.orEmpty(), sensorFrom, sensorTo) + writer.flush() + } + out.closeEntry() + + val sensorAvailable = imuSegments.isNotEmpty() || v18Count > 0 || rawCounts.values.any { it > 0 } + out.putNextEntry(ZipEntry("meta.json")) + out.writerEntry(JSONObject().apply { + put("schema_version", 3) + put("capture_kind", "whoop_5mg_raw_data") + put("session_id", id) + if (deviceId != null) put("device_id", deviceId) + put("sensor_export_available", sensorAvailable) + put("imu_100hz_segment_count", imuSegments.size) + put("imu_100hz_complete", imuComplete) + put("imu_100hz_required_start_ts", coverageFrom) + put("imu_100hz_required_end_ts", fullTo) + put("imu_100hz_startup_seconds", (coverageFrom - fullFrom).coerceAtLeast(0)) + put("imu_100hz_coverage", org.json.JSONArray().apply { + imuSegments.forEach { segment -> put(JSONObject().apply { + put("file", segment.name); put("start_ts", segment.startTs); put("end_ts", segment.endTs) + put("sample_count", segment.sampleCount) + }) } + }) + put("started_at_ms", summary.startedAtMs) + put("ended_at_ms", endMs) + summary.capturedStartedAtMs?.let { put("captured_started_at_ms", it) } + summary.capturedEndedAtMs?.let { put("captured_ended_at_ms", it) } + put("comment", summary.comment) + put("markers", org.json.JSONArray().apply { + summary.markers.filter { it.atMs in summary.startedAtMs..endMs }.forEach { marker -> put(JSONObject().apply { + put("id", marker.id); put("at_ms", marker.atMs); put("type", marker.type); put("text", marker.text) + }) } }) + put("device_family", "Android") + put("app_version", BuildConfig.VERSION_NAME) + }.toString(2)) + + out.putNextEntry(ZipEntry("events.jsonl")) + out.writerEntry(eventsJsonl(publicEvents(events, summary.startedAtMs, endMs, deviceId))) + + out.putNextEntry(ZipEntry("events.csv")) + out.writerEntry(eventsCsv(publicEvents(events, summary.startedAtMs, endMs, deviceId))) + + for (segment in imuSegments) { + out.putNextEntry(ZipEntry("imu/${segment.name}")) + out.write(segment.data) + out.closeEntry() + } + } + val exportedAt = System.currentTimeMillis() + prefs.edit().putBoolean(sessionExportedKey(id), true).putLong(sessionLastExportKey(id), exportedAt).apply() + if (id == snap.sessionId) prefs.edit().putBoolean("exported", true).apply() + zip + } + + fun share(file: File) { + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + context.startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).apply { + type = "application/zip" + putExtra(Intent.EXTRA_STREAM, uri) + putExtra(Intent.EXTRA_SUBJECT, "NOOP 5/MG raw-data session") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + }, "Export raw-data session").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + + private fun covers(chunks: List, from: Long, to: Long): Boolean { + if (from > to) return false + var cursor = from + for (chunk in chunks.sortedBy { it.startTs }) { + val start = chunk.startTs; val end = chunk.endTs + if (chunk.sampleCount < (end - start + 1) * ImuSessionFileStore.SAMPLE_RATE) continue + if (start > cursor) return false + if (end >= cursor) cursor = end + 1 + if (cursor > to) return true + } + return cursor > to + } + + private fun ceilSecond(epochMs: Long): Long = (epochMs + 999L) / 1_000L + + private fun eventFile(id: String) = File(directory, "session-$id.jsonl") + private fun append(id: String, value: JSONObject) = eventFile(id).appendText(value.toString() + "\n") + + private fun event(atMs: Long, kind: String) = JSONObject().apply { put("at_ms", atMs); put("kind", kind) } + + private fun publicEvents(events: List, fromMs: Long, toMs: Long, deviceId: String?): List = + buildList { + add(event(fromMs, "start").apply { deviceId?.let { put("strap_device_id", it) } }) + events.filter { it.optString("kind") == KIND_MARKER && it.optLong("at_ms") in fromMs..toMs } + .forEach { source -> add(JSONObject().apply { + put("at_ms", source.optLong("at_ms")); put("kind", KIND_MARKER) + put("marker_id", source.optString("marker_id")); put("marker_type", source.optString("marker_type")) + put("text", source.optString("text")) + }) } + add(event(toMs, "stop")) + }.sortedBy { it.optLong("at_ms") } + + private fun eventsJsonl(events: List) = events.joinToString("\n", postfix = "\n") + + private fun eventsCsv(events: List): String = buildString { + append("at_ms,unix_s,kind,marker_id,marker_type,text\n") + for (e in events) append(listOf( + e.optLong("at_ms"), e.optLong("at_ms") / 1_000L, e.optString("kind"), + e.optString("marker_id"), e.optString("marker_type"), e.optString("text"), + ).joinToString(",") { csv(it.toString()) }).append('\n') + } + + private fun csv(value: String): String = if (value.any { it == ',' || it == '"' || it == '\n' }) + "\"${value.replace("\"", "\"\"")}\"" else value + + private suspend fun writeV18AuxCsv( + out: java.io.Writer, + repo: WhoopRepository, + deviceId: String, + from: Long, + to: Long, + ): Int { + out.write( + "unix_s,record_index,rr_count,cardiac_flags,hr_quality_flags,heart_rate_alt,rr_packed," + + "cardiac_status,step_cadence,status_word,status_word_1,status_word_2,aux_byte_82," + + "optical_baseline_a,optical_baseline_b,optical_amp_a,optical_amp_b,unknown_f32_bits\n", + ) + if (from > to || deviceId.isBlank()) return 0 + val seconds = (to - from + 1L).coerceIn(1L, 86_400L).toInt() + val rows = repo.v18AuxSamples(deviceId, from, to, seconds) + for (row in rows) { + out.write((listOf(row.ts) + row.slotValues.map { it ?: "" }).joinToString(",")) + out.write("\n") + } + return rows.size + } + + private fun android.content.SharedPreferences.Editor.clearCurrentSession() = apply { + remove("active") + remove("exported") + remove("sessionId") + remove("deviceId") + remove("startedAtMs") + remove("endedAtMs") + } + + private fun sessionDeviceKey(id: String) = "session.$id.deviceId" + private fun sessionStartKey(id: String) = "session.$id.startedAtMs" + private fun sessionEndKey(id: String) = "session.$id.endedAtMs" + private fun sessionCommentKey(id: String) = "session.$id.comment" + private fun sessionExportedKey(id: String) = "session.$id.exported" + private fun sessionLastExportKey(id: String) = "session.$id.lastExportedAtMs" + private fun sessionUsedStartKey(id: String) = "session.$id.usedStartedAtMs" + private fun sessionUsedEndKey(id: String) = "session.$id.usedEndedAtMs" + private fun sessionCaptureKey(id: String) = "session.$id.hasRealtimeCapture" + + private fun ZipOutputStream.writerEntry(text: String) { + write(text.toByteArray(Charsets.UTF_8)); closeEntry() + } + + /** A Writer whose close only flushes, because closing it must not close the surrounding ZIP. */ + private fun ZipOutputStream.bufferedWriterNoClose() = object : java.io.OutputStreamWriter(this, Charsets.UTF_8) { + override fun close() = flush() + }.buffered() + + companion object { + private const val KIND_MARKER = "marker" + private const val PREFS = "noop_ground_truth_collector" + private const val MAX_RANGE_MS = 7L * 24 * 60 * 60 * 1_000 + + fun from(context: Context) = GroundTruthCollector(context.applicationContext) + } +} diff --git a/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt b/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt new file mode 100644 index 0000000000..c7312bf6fc --- /dev/null +++ b/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt @@ -0,0 +1,225 @@ +package com.noop.testcentre + +import android.content.Context +import com.noop.data.StreamPersistence +import com.noop.protocol.Whoop5RawImu +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.EOFException +import java.io.File +import java.io.FileOutputStream +import java.util.zip.Deflater +import java.util.zip.Inflater + +/** Stores decoded 100 Hz IMU in fixed UTC half-hour segments owned by one capture session. */ +class ImuSessionFileStore(private val context: Context) { + data class Stats(val bytes: Long, val coveredSeconds: Int, val firstTs: Long?) + data class ExportSegment(val name: String, val data: ByteArray, val startTs: Long, val endTs: Long, + val sampleCount: Int) + private data class Record(val ts: Long, val receivedAtMs: Long, val columns: ShortArray) + private val prefs = context.getSharedPreferences("imu-session-windows", Context.MODE_PRIVATE) + private val directory = File(context.filesDir, "raw-imu-sessions").apply { mkdirs() } + + fun start(id: String, deviceId: String, fromMs: Long) = synchronized(lock) { + prefs.edit().putStringSet("ids", ids() + id).putString("$id.device", deviceId) + .putLong("$id.from", fromMs / 1_000L).putLong("$id.fromMs", fromMs) + .remove("$id.to").remove("$id.toMs").apply() + sessionDir(id).mkdirs() + } + + fun complete(id: String, toMs: Long) = synchronized(lock) { + flushSession(id) + if (id in ids()) prefs.edit().putLong("$id.to", toMs / 1_000L).putLong("$id.toMs", toMs).apply() + } + + fun register(id: String, deviceId: String, fromMs: Long, toMs: Long) = synchronized(lock) { + start(id, deviceId, fromMs); complete(id, toMs) + } + + /** Remove routing only. Files are removed separately so callers can keep deletion retryable. */ + fun remove(id: String) = synchronized(lock) { + pending.keys.filter { it.startsWith("$id/") }.forEach(pending::remove) + seen.keys.filter { it.startsWith(sessionDir(id).absolutePath) }.forEach(seen::remove) + prefs.edit().putStringSet("ids", ids() - id).remove("$id.device").remove("$id.from") + .remove("$id.fromMs").remove("$id.to").remove("$id.toMs").apply() + } + + /** True while a bounded capture is active or still missing seconds recoverable from strap history. */ + fun needsHighThroughput(deviceId: String): Boolean = synchronized(lock) { + ids().any { id -> + if (prefs.getString("$id.device", null) != deviceId) return@any false + if (!prefs.contains("$id.to")) return@any true + val fromMs = prefs.getLong("$id.fromMs", prefs.getLong("$id.from", 0L) * 1_000L) + val toMs = prefs.getLong("$id.toMs", prefs.getLong("$id.to", 0L) * 1_000L) + val baseFrom = (fromMs + 999L) / 1_000L + val to = toMs / 1_000L - 1L + if (baseFrom > to) return@any false + val present = buildSet { + segmentFiles(id).forEach { addAll(timestamps(it)) } + pending.filterKeys { it.startsWith("$id/") }.values.flatten().forEach { add(it.ts) } + } + // Match export's one-second producer-start allowance, without hiding any later gap. + val first = present.minOrNull()?.takeIf { it <= baseFrom + 1 } ?: baseFrom + (first..to).any { it !in present } + } + } + + fun prepareForRead(id: String) = synchronized(lock) { flushSession(id) } + + fun deleteFiles(id: String): Boolean = synchronized(lock) { + flushSession(id) + val dir = sessionDir(id) + if (dir.listFiles().orEmpty().any { it.isDirectory || !it.delete() }) return@synchronized false + !dir.exists() || dir.delete() + } + + fun deleteDevice(deviceId: String): Boolean = synchronized(lock) { + val owned = ids().filter { prefs.getString("$it.device", null) == deviceId } + if (owned.any { !deleteFiles(it) }) return@synchronized false + owned.forEach(::remove) + true + } + + fun stats(id: String, from: Long, to: Long): Stats = synchronized(lock) { + // Coverage needs timestamps, not decoded 6-axis payloads. Reuse the per-file timestamp index + // instead of inflating every .imus block on each one-second UI refresh. + val covered = buildSet { + segmentFiles(id).forEach { file -> addAll(timestamps(file).filter { it in from..to }) } + pending.filterKeys { it.startsWith("$id/") }.values.flatten() + .forEach { record -> if (record.ts in from..to) add(record.ts) } + } + val disk = segmentFiles(id).sumOf { it.length() } + val pendingBytes = pending.filterKeys { it.startsWith("$id/") }.values.flatten() + .sumOf { it.columns.size.toLong() * 2 + RECORD_HEADER_BYTES } + Stats(disk + pendingBytes, covered.size, covered.minOrNull()) + } + + fun append(deviceId: String, frame: ByteArray, receivedAtMs: Long = System.currentTimeMillis()): Int = synchronized(lock) { + val decoded = Whoop5RawImu.decode(frame) ?: return 0 + val columns = Whoop5RawImu.rawColumns(frame) ?: return 0 + val ts = decoded.baseTs + var writes = 0 + for (id in ids()) { + if (prefs.getString("$id.device", null) != deviceId) continue + val from = prefs.getLong("$id.from", Long.MAX_VALUE) + val to = if (prefs.contains("$id.to")) prefs.getLong("$id.to", Long.MIN_VALUE) else Long.MAX_VALUE + if (ts !in from..to) continue + val bucket = bucketStart(ts); val file = segmentFile(id, bucket) + if (!timestamps(file).add(ts)) continue + val key = "$id/$bucket" + pending.getOrPut(key) { mutableListOf() } += Record(ts, receivedAtMs, columns) + if (pending[key]!!.size >= BLOCK_SECONDS) flushKey(key) + writes++ + } + writes + } + + /** Exact selected interval, still in the canonical decoded .imus format. */ + fun exportSegments(id: String, from: Long, to: Long): List = synchronized(lock) { + flushSession(id) + readRecords(id, from, to, includePending = false).distinctBy { it.ts }.sortedBy { it.ts } + .groupBy { bucketStart(it.ts) }.toSortedMap().map { (bucket, records) -> + ExportSegment("imu-${utcName(bucket)}.imus", encodeFile(bucket, records), records.first().ts, + records.last().ts, records.size * SAMPLE_RATE) + } + } + + private fun readRecords(id: String, from: Long, to: Long, includePending: Boolean): List { + val rows = segmentFiles(id).flatMap { decodeFile(it.readBytes()) }.filter { it.ts in from..to }.toMutableList() + if (includePending) rows += pending.filterKeys { it.startsWith("$id/") }.values.flatten().filter { it.ts in from..to } + return rows + } + + private fun encodeFile(bucket: Long, records: List): ByteArray { + val output = ByteArrayOutputStream() + DataOutputStream(output).use { out -> + writeHeader(out, bucket); records.chunked(BLOCK_SECONDS).forEach { writeBlock(out, it) } + } + return output.toByteArray() + } + + private fun decodeFile(bytes: ByteArray): List { + if (bytes.size < FILE_HEADER_BYTES || !bytes.copyOfRange(0, MAGIC.size).contentEquals(MAGIC)) return emptyList() + val rows = mutableListOf() + DataInputStream(ByteArrayInputStream(bytes)).use { input -> + input.skipBytes(MAGIC.size); input.readLong() + if (input.readInt() != SAMPLE_RATE || input.readInt() != AXES) return emptyList() + while (true) try { + val count = input.readInt(); val rawSize = input.readInt(); val compressedSize = input.readInt() + if (count !in 1..BLOCK_SECONDS || rawSize !in 1..MAX_BLOCK_BYTES || compressedSize !in 1..MAX_BLOCK_BYTES) break + val compressed = ByteArray(compressedSize); input.readFully(compressed) + val raw = inflate(compressed, rawSize) ?: break + var valid = true + DataInputStream(ByteArrayInputStream(raw)).use { block -> for (index in 0 until count) { + val ts = block.readLong(); val received = block.readLong(); val length = block.readInt() + if (length != PAYLOAD_BYTES) { valid = false; break } + val packed = ByteArray(length); block.readFully(packed) + rows += Record(ts, received, unpackColumns(packed)) + }} + if (!valid) break + } catch (_: EOFException) { break } + } + return rows + } + + private fun flushSession(id: String) = pending.keys.filter { it.startsWith("$id/") }.toList().forEach(::flushKey) + private fun flushKey(key: String) { + val records = pending.remove(key).orEmpty(); if (records.isEmpty()) return + val id = key.substringBefore('/'); val bucket = key.substringAfter('/').toLong() + val file = segmentFile(id, bucket); file.parentFile?.mkdirs(); val newFile = !file.exists() + DataOutputStream(FileOutputStream(file, true).buffered()).use { out -> + if (newFile) writeHeader(out, bucket) + writeBlock(out, records) + } + } + + private fun writeHeader(out: DataOutputStream, bucket: Long) { + out.write(MAGIC); out.writeLong(bucket); out.writeInt(SAMPLE_RATE); out.writeInt(AXES) + } + + private fun writeBlock(out: DataOutputStream, records: List) { + val rawBytes = ByteArrayOutputStream() + DataOutputStream(rawBytes).use { block -> records.forEach { record -> + val packed = StreamPersistence.packImuColumns(record.columns) + block.writeLong(record.ts); block.writeLong(record.receivedAtMs); block.writeInt(packed.size); block.write(packed) + }} + val raw = rawBytes.toByteArray(); val compressed = deflate(raw) + out.writeInt(records.size); out.writeInt(raw.size); out.writeInt(compressed.size); out.write(compressed) + } + + private fun timestamps(file: File): MutableSet = seen.getOrPut(file.absolutePath) { + decodeFile(file.takeIf(File::isFile)?.readBytes() ?: byteArrayOf()).mapTo(mutableSetOf()) { it.ts } + } + private fun ids() = prefs.getStringSet("ids", emptySet()).orEmpty() + private fun sessionDir(id: String) = File(directory, id) + private fun segmentFiles(id: String) = sessionDir(id).listFiles { f -> f.isFile && f.extension == "imus" } + .orEmpty().sortedBy { it.name } + private fun segmentFile(id: String, bucket: Long) = File(sessionDir(id), "imu-${utcName(bucket)}.imus") + + private fun deflate(raw: ByteArray): ByteArray { + val d = Deflater(); d.setInput(raw); d.finish(); val out = ByteArray(raw.size + 128) + val size = d.deflate(out); d.end(); return out.copyOf(size) + } + private fun inflate(compressed: ByteArray, size: Int): ByteArray? { + val i = Inflater(); i.setInput(compressed); val out = ByteArray(size) + val written = runCatching { i.inflate(out) }.getOrDefault(0); i.end(); return out.takeIf { written == size } + } + private fun unpackColumns(bytes: ByteArray) = ShortArray(bytes.size / 2) { index -> + ((bytes[index * 2].toInt() and 0xff) or (bytes[index * 2 + 1].toInt() shl 8)).toShort() + } + + companion object { + private val lock = Any(); private val seen = mutableMapOf>() + private val pending = mutableMapOf>() + private val MAGIC = "NOOPIMU2".toByteArray(Charsets.US_ASCII) + const val SAMPLE_RATE = 100; const val AXES = 6; const val BLOCK_SECONDS = 30 + const val SEGMENT_SECONDS = 30 * 60L; const val PAYLOAD_BYTES = SAMPLE_RATE * AXES * 2 + private const val RECORD_HEADER_BYTES = 20L; private const val FILE_HEADER_BYTES = 24 + private const val MAX_BLOCK_BYTES = BLOCK_SECONDS * (PAYLOAD_BYTES + 20) + internal fun bucketStart(ts: Long) = Math.floorDiv(ts, SEGMENT_SECONDS) * SEGMENT_SECONDS + internal fun utcName(ts: Long): String = java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'") + .withZone(java.time.ZoneOffset.UTC).format(java.time.Instant.ofEpochSecond(ts)) + } +} diff --git a/android/app/src/main/java/com/noop/testcentre/ReportCompleteness.kt b/android/app/src/main/java/com/noop/testcentre/ReportCompleteness.kt index 883445a8f7..a64118b3d6 100644 --- a/android/app/src/main/java/com/noop/testcentre/ReportCompleteness.kt +++ b/android/app/src/main/java/com/noop/testcentre/ReportCompleteness.kt @@ -49,7 +49,13 @@ object ReportCompleteness { TestDomain.IMPORT to "import parser=", TestDomain.STEPS to "stepsEst ", TestDomain.BATTERY to "battery series=", - TestDomain.RECOVERY to "charge score=", + // "charge day=", not "charge score=". Every recovery trace line is re-emitted by + // IntelligenceEngine.recoveryTraceLines as `charge day= ` + the body, so the bare + // "charge score=" this used to look for CANNOT occur and the domain reported MISSING on every + // capture that had a perfectly healthy trace. The day prefix is also the one part shared by + // every variant the trace emits - score, nilScore and each term - so it cannot go stale the + // way a token naming one variant did. + TestDomain.RECOVERY to "charge day=", TestDomain.HRV to "hrv rmssd=", ) @@ -129,6 +135,21 @@ object ReportCompleteness { return out } + /** + * How long a profile must have been running before "complete" means anything. + * + * A trace only records while its profile is on, so a capture started seconds before the export + * contains none of the passes it is meant to evidence. That is not hypothetical: a sleep bundle + * arrived with its profile started NINE SECONDS before the export, carrying no `[sleep]` lines at + * all, and this section still said "complete: all active traces present" - which is exactly what + * told the reporter it was ready to send, and cost a full round trip to discover. + * + * Two minutes, because the thing most captures need to contain is a scoring pass, and one takes + * about forty seconds on a large library. Short enough not to nag, long enough that a capture + * clearing it plausibly holds one. + */ + const val MIN_PROFILE_SECONDS = 120L + /** * The "Capture check" section appended to report.txt: a header, then one line per checked domain in * deterministic order, then a footer flag when any active domain's trace is MISSING (the at-a-glance @@ -137,8 +158,16 @@ object ReportCompleteness { * `()`, an evidence-only match (#127) reads `(via )`, and MISSING reads * `(expected )` — the Swift renderer's "expected …" wording for the missing case. Returns * the section WITHOUT a leading newline; the assembler joins it. + * + * @param profileRanSeconds how long the profile had been running when the bundle was assembled, + * or null when that is unknown. Null is treated as "cannot judge" and says nothing, because + * inventing a warning from a missing timestamp would be its own false signal. */ - fun captureCheckSection(reportText: String, active: Set): String { + fun captureCheckSection( + reportText: String, + active: Set, + profileRanSeconds: Long? = null, + ): String { val statuses = statuses(reportText, active) val sb = StringBuilder() sb.append("=== Capture check ===") @@ -171,12 +200,30 @@ object ReportCompleteness { } else { sb.append("INCOMPLETE: missing ").append(missingActive.joinToString(", ")) } + // A short profile is reported WHATEVER the trace verdict was, and deliberately after it: a + // capture can hold every token it needs and still be too young to hold the pass that explains + // the bug, and a reader who has just been told "complete" is precisely the one who needs to + // know that. Phrased as what to do, not as a fault. + // `in 0 until MIN`, not `< MIN`: a clock moved backwards between starting the profile and + // exporting yields a NEGATIVE age, and "the profile ran -412s before this export" is a worse + // line than saying nothing. A nonsense age is unknown, and unknown says nothing. + if (profileRanSeconds != null && profileRanSeconds in 0 until MIN_PROFILE_SECONDS) { + sb.append("\nTOO SHORT: the profile ran ").append(profileRanSeconds) + .append("s before this export. Traces only record while the profile is on, so a scoring") + .append(" pass may not have happened yet. Leave it running a couple of minutes, then") + .append(" export again.") + } return sb.toString() } /** The meta.json `capture_check` value: a {domainId -> "present"|"MISSING"} map plus the `complete` * flag, for the machine-readable tie. Keys are the wire ids; emitted in sorted order by TestBundleMeta - * so the JSON bytes line up with the Swift twin. */ + * so the JSON bytes line up with the Swift twin. + * + * `complete` means TRACES PRESENT and nothing more, so it can read true on a capture whose section + * carries the short-profile note. That is deliberate rather than an oversight: widening the flag + * would change what every existing consumer thinks it asks. The duration a machine reader needs is + * already in the meta beside this, as `profile_started_at`. */ fun captureCheckMeta(reportText: String, active: Set): CaptureCheckMeta { val statuses = statuses(reportText, active) val map = LinkedHashMap() diff --git a/android/app/src/main/java/com/noop/testcentre/TestBundleAssembler.kt b/android/app/src/main/java/com/noop/testcentre/TestBundleAssembler.kt index c9a6e728c0..2088e055c2 100644 --- a/android/app/src/main/java/com/noop/testcentre/TestBundleAssembler.kt +++ b/android/app/src/main/java/com/noop/testcentre/TestBundleAssembler.kt @@ -105,7 +105,7 @@ object TestBundleAssembler { // strap-log share writes. Already scrubbed by the log() sink; the redactEntries pass re-scrubs. val header = buildString { appendLine("NOOP strap log") - appendLine("App: ${BuildConfig.VERSION_NAME} (${BuildConfig.TIER})") + appendLine("App: ${BuildConfig.VERSION_NAME} (${BuildConfig.TIER}) build ${BuildConfig.VERSION_CODE}") for (line in AndroidDiagnostics.summaryLines(context)) appendLine(line) appendLine("-".repeat(40)) } @@ -116,7 +116,11 @@ object TestBundleAssembler { // domain, whether its killer trace landed. Computed over the header+body that will ship (the guard // reads exactly what the maintainer reads). Byte-identical section to the Swift twin. val reportBody = header + "\n" + body - val captureCheck = ReportCompleteness.captureCheckSection(reportBody, activeDomains) + // The profile's age is part of whether this capture can be believed, so it is computed BEFORE + // the section rather than only stamped into meta.json where nobody reading report.txt sees it. + val ranSeconds = tc.startedAt(profile)?.let { (System.currentTimeMillis() / 1000L) - it } + val captureCheck = + ReportCompleteness.captureCheckSection(reportBody, activeDomains, ranSeconds) val reportText = reportBody + "\n" + captureCheck val entries = ArrayList>() entries.add("report.txt" to reportText.toByteArray()) diff --git a/android/app/src/main/java/com/noop/testcentre/TestBundleMeta.kt b/android/app/src/main/java/com/noop/testcentre/TestBundleMeta.kt index 4bf35d66e5..d8a0adf550 100644 --- a/android/app/src/main/java/com/noop/testcentre/TestBundleMeta.kt +++ b/android/app/src/main/java/com/noop/testcentre/TestBundleMeta.kt @@ -25,7 +25,13 @@ data class TestBundleMeta( val captureCheck: CaptureCheck, ) { data class Build(val channel: String, val signed: Boolean) - data class Storage(val dbBytes: Int, val rows: Map, val rawCaptureBytes: Int) + data class Storage( + val dbBytes: Int, + val rows: Map, + val rawCaptureBytes: Int, + /** #1911: estimated payload bytes per table, keyed as [rows] is. Swift twin: `Storage.rowBytes`. */ + val rowBytes: Map = emptyMap(), + ) /** The report-completeness tie (twin of the Swift CaptureCheck): per-domain killer-trace presence * ({domainId -> "present"|"MISSING"}) plus the overall `complete` flag, so a maintainer can tell at @@ -53,7 +59,10 @@ data class TestBundleMeta( "storage" to mapOf( "db_bytes" to storage.dbBytes, "raw_capture_bytes" to storage.rawCaptureBytes, - "rows" to storage.rows), + "rows" to storage.rows, + // #1911: bytes beside the counts, so this block matches the Apple bundle's. Rows alone + // cannot say which table holds a large store - ppgWaveformSample's rows carry a BLOB. + "row_bytes" to storage.rowBytes), "strap_model" to strapModel, "test_profile" to testProfile, "truncated" to truncated) diff --git a/android/app/src/main/java/com/noop/ui/ActiveDayCycle.kt b/android/app/src/main/java/com/noop/ui/ActiveDayCycle.kt new file mode 100644 index 0000000000..af13625688 --- /dev/null +++ b/android/app/src/main/java/com/noop/ui/ActiveDayCycle.kt @@ -0,0 +1,36 @@ +package com.noop.ui + +import com.noop.analytics.DayCycleMode +import com.noop.data.DailyMetric +import com.noop.data.MetricSeriesRow + +/** Active boundary shared by every additive Today metric; steps are optional on counter-less devices. */ +internal data class ActiveDayCycle(val ownerDay: String, val onsetTs: Long, val steps: Int?) + +internal fun effectiveActiveStrapId(published: String?, fallback: String): String = + published?.takeIf { it.isNotBlank() } ?: fallback + +/** The one shared start used by every live additive metric on Today. */ +internal fun activeDayCycleStart( + mode: DayCycleMode, + confirmedOrSyntheticOnset: Long?, + calendarStart: Long, +): Long = if (mode == DayCycleMode.SLEEP_ONSET) confirmedOrSyntheticOnset ?: calendarStart else calendarStart + +/** Resolve the newest valid boundary even when this device has no raw step counter. */ +internal fun resolveActiveDayCycle( + visibleDays: List, + computedDays: List, + onsetMarkers: List, + nowSeconds: Long, +): ActiveDayCycle? { + val visibleDayKeys = visibleDays.mapTo(HashSet()) { it.day } + val computedByDay = computedDays.associateBy { it.day } + return onsetMarkers.asSequence() + .filter { it.value.isFinite() && it.value > 0.0 && it.value <= nowSeconds.toDouble() } + .filter { it.day in visibleDayKeys } + .maxByOrNull { it.value } + ?.let { marker -> + ActiveDayCycle(marker.day, marker.value.toLong(), computedByDay[marker.day]?.steps) + } +} diff --git a/android/app/src/main/java/com/noop/ui/ActiveWorkoutClock.kt b/android/app/src/main/java/com/noop/ui/ActiveWorkoutClock.kt new file mode 100644 index 0000000000..8916e504cc --- /dev/null +++ b/android/app/src/main/java/com/noop/ui/ActiveWorkoutClock.kt @@ -0,0 +1,40 @@ +package com.noop.ui + +/** + * The ONE pause-aware elapsed calculation for an in-flight workout, shared by every surface that shows + * its clock. + * + * #1533 added Pause/Resume to the full-screen live workout screen and taught THAT screen's timer to + * subtract the paused time. The Today "workout in progress" card and the Live tab's active-workout card + * were not part of it: both still read `now - startMs`. So pausing froze one clock and left two others + * counting up, and a wearer who paused and left the screen saw the session apparently still running. The + * saved duration was correct the whole time, which is what made it confusing rather than merely wrong — + * the only evidence on screen contradicted the button that had just been pressed. + * + * Kept pure and in one place so a new surface cannot reintroduce the divergence by open-coding the + * subtraction again. Twin of Swift `ActiveWorkoutClock`. + */ +object ActiveWorkoutClock { + + /** + * Seconds of ACTIVE time: wall time since [startMs], minus every completed pause + * ([pausedDurationMs]), minus the one still open when [pausedAtMs] is non-null. + * + * Clamped at zero so a clock-skew negative reads 0:00 rather than counting backwards. The open-pause + * term is deliberately NOT clamped on its own: the single clamp on the result is what the Swift twin + * does, and two clamps would disagree with it for a [pausedAtMs] in the future. + * + * Twin of Swift `ActiveWorkoutClock.activeElapsed` — the NAMES differ (that one takes + * `Date`/`TimeInterval`, this one Long milliseconds), so neither turns up in a grep for the other. + * Same arithmetic and the same single clamp. + */ + fun activeElapsedSeconds( + startMs: Long, + pausedAtMs: Long?, + pausedDurationMs: Long, + nowMs: Long, + ): Long { + val openPauseMs = pausedAtMs?.let { nowMs - it } ?: 0L + return ((nowMs - startMs - pausedDurationMs - openPauseMs) / 1000).coerceAtLeast(0L) + } +} diff --git a/android/app/src/main/java/com/noop/ui/AddDeviceWizard.kt b/android/app/src/main/java/com/noop/ui/AddDeviceWizard.kt index cd97888b81..d3cde96a9a 100644 --- a/android/app/src/main/java/com/noop/ui/AddDeviceWizard.kt +++ b/android/app/src/main/java/com/noop/ui/AddDeviceWizard.kt @@ -700,7 +700,7 @@ private fun ouraHeaderSubtitle(step: OuraStep, advanced: Boolean): String? = whe @Composable private fun TypeStep(onPick: (DeviceType) -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - TypeRow(Icons.Filled.Watch, DeviceType.Whoop5MG.title, "Newer WHOOP band. Experimental in NOOP") { + TypeRow(Icons.Filled.Watch, DeviceType.Whoop5MG.title, "Newer WHOOP band with live data and history sync") { onPick(DeviceType.Whoop5MG) } TypeRow(Icons.Filled.Watch, DeviceType.Whoop4.title, "NOOP's primary, fully-supported band") { @@ -1338,7 +1338,10 @@ private fun OuraPickStep( DiscoveredRow( name = ring.name, // Subtitle = the detected generation (best-effort); "Oura ring" when undetected. - subtitle = ring.detectedGen?.displayName ?: "Oura ring", + // Localised: a ring that advertises no local name has no detectable generation + // either, so this fallback is exactly what the #1776 bonded-ring case renders. + subtitle = ring.detectedGen?.displayName + ?: uiString(R.string.l10n_add_device_wizard_oura_ring_e3431536), rssi = ring.rssi, onTap = { onPick(ring) }, ) diff --git a/android/app/src/main/java/com/noop/ui/AppChangelog.kt b/android/app/src/main/java/com/noop/ui/AppChangelog.kt index 70b4938dd8..b6c14bd9ab 100644 --- a/android/app/src/main/java/com/noop/ui/AppChangelog.kt +++ b/android/app/src/main/java/com/noop/ui/AppChangelog.kt @@ -26,7 +26,7 @@ object AppChangelog { * Bump this when you add a release below. The "What's New" sheet shows automatically when the * stored last-seen version is behind this. (Decoupled from the bundle version on purpose.) */ - const val CURRENT_VERSION = "10.6.0" + const val CURRENT_VERSION = "11.1.0" data class Release( val version: String, @@ -37,6 +37,35 @@ object AppChangelog { /** Newest first. */ val releases: List = listOf( + Release( + version = "11.1.0", + title = uiString(R.string.l10n_app_changelog_choose_a_12_hour_clock_sleep_8a19db5c), + date = "September 2026", + items = listOf( + "**Pick the clock you read times in (#1821).** Settings → Appearance now offers System, 12-hour or 24-hour. It defaults to System, so nothing changes unless you ask — and System now means your phone's own 24-hour switch, which NOOP was previously ignoring in favour of your region's default. A reader in a 24-hour country who prefers 12-hour had no way to say so.", + "**Sleep from a strap that records no motion (#1801).** A WHOOP 5/MG that never pairs banks no movement data, and sleep detection is built on stillness — so those nights scored as nothing at all. NOOP can now find a night from heart rate alone and stage it. It is display-only by design: an HR-only night never feeds your resting heart rate or HRV baselines, because it has not earned that.", + "**A charged strap is no longer told to charge (#1818).** The 1970/71 clock warning offered one remedy regardless of battery, so people at 100% were sent round a loop they had already run. It now says something true for a strap that is already charged, and asks for the log that can actually explain it.", + "**The coach can see your sleep stages (#1816).** It was answering that it had no access to them, because deep, REM and light minutes were never in what it was given. They are now, along with sleep efficiency.", + "**Back up a database larger than NOOP would restore (#1807).** Export warns when an archive is past the 2 GiB restore ceiling instead of writing it silently, and a restore can be allowed through rather than refused outright.", + "**Home-screen widget corrections (#1795, thanks @Sneheth; #1799).** The heart-rate and HRV values sat under each other's icons on Apple. Both platforms' widgets now also read properly aloud, with the value spoken rather than the raw number.", + "**Live workout no longer stacks two timers (#1814), and the steps card stops asking for the half you already gave it (#1815).**", + "**A strap log that reports rather than assumes (#1809, thanks @supremesynergy; #1823).** Every disconnect now records how long the link held and whether the strap sent anything at all, and the clock exchange quotes what the strap actually answered. NOOP used to write \"clock synced\" the moment it queued the write, before any reply existed — so a log could insist the clock was set while the screen said 1970/71.", + "**Oura rings with no name, and Android's heart-rate logging (#1797, thanks @pipiche38; #1796, thanks @kvnloo).**", + ), + ), + Release( + version = "11.0.0", + title = uiString(R.string.l10n_app_changelog_a_whoop_5_that_stays_connected_7b490eee), + date = "September 2026", + items = listOf( + "**The WHOOP 5.0 and MG stop dropping every few seconds (#1635, thanks @Zebsi235).** A handshake the strap never answers was knocking a perfectly good link down about every five seconds, all day. NOOP now recognises a strap that will not complete that handshake, stops attempting it, and holds the link instead — live heart rate keeps streaming rather than restarting forever. Tapping Connect costs one reconnect now, not five.", + "**Your body clock, on the Sleep screen (#1722, #1723, #1729, #1733).** A 24-hour dial showing when your body actually wants to sleep, and a chronotype read from your own nights rather than a questionnaire. It says what it used and refuses to guess when it cannot see enough.", + "**Nights read straighter (#1717, #1734, #1741, #1755, #1782).** A night that only partly downloaded is no longer scored as if it were whole. Sleep that arrives in fragments is stitched back together when your heart rate says you stayed asleep through the gap. And sleep debt is now a number you can act on tonight.", + "**A day you never logged is not a day you answered No (#322).** Journal insights compared days you ticked against every other day — including the ones you simply did not open the app. They now compare Yes days against No days only, so an untracked week stops quietly counting against you.", + "**NOOP tells you when there is a new version (#1674, #1675).** Both platforms now notice a release and say so, on by default, checked directly against the release feed with nothing else sent.", + "**Health Connect asks for one category at a time (#1509, thanks @kavemang).** Recovery, Activity and Body composition are chosen before Android's prompt appears, so you grant what you meant to. Existing installs keep exactly what they already had.", + ), + ), Release( version = "10.6.0", title = uiString(R.string.l10n_app_changelog_an_effort_scale_you_choose_a_a7c2478d), diff --git a/android/app/src/main/java/com/noop/ui/AppRoot.kt b/android/app/src/main/java/com/noop/ui/AppRoot.kt index fc533ab50b..60909d9b99 100644 --- a/android/app/src/main/java/com/noop/ui/AppRoot.kt +++ b/android/app/src/main/java/com/noop/ui/AppRoot.kt @@ -1,5 +1,6 @@ package com.noop.ui +import android.content.Context import androidx.annotation.StringRes import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.animateFloatAsState @@ -97,6 +98,17 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.derivedStateOf +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavHostController @@ -104,6 +116,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import com.noop.push.SelfHostedPushScreen // MARK: - Navigation model // @@ -171,6 +184,8 @@ private enum class Destination( Notifications("notifications", R.string.nav_notifications, Icons.Filled.Notifications), PowerSaving("power_saving", R.string.nav_power_saving, Icons.Filled.BatteryStd), Settings("settings", R.string.nav_settings, Icons.Filled.Settings), + // Experimental and intentionally absent from More: reachable only through Settings > Advanced. + SelfHostedPush("self_hosted_push", R.string.nav_self_hosted_push, Icons.Filled.CloudSync), // Nested Settings destination shared by the Settings row and a blank WHOOP 4.0 Steps tile (#1515). // Deliberately absent from [drawerGroups]: it is contextual, not another top-level More item. StepsCalibration( @@ -179,6 +194,7 @@ private enum class Destination( Icons.Filled.Tune, ), TestCentre("test_centre", R.string.nav_test_centre, Icons.Filled.BugReport), + GroundTruthCollector("ground_truth_collector", R.string.ground_truth_title, Icons.Filled.Sensors), // The "More" tab: its own navigated page (mirroring the iOS More tab) that hosts the full // grouped destination list. It is NOT itself in any [DrawerGroup] — it's the door to them. @@ -265,6 +281,177 @@ internal object MoreSectionPrefs { raw.split(",").map { it.trim() }.filter { it.isNotEmpty() }.toSet() } +/** + * #1839: should the overlay bar be hidden right now? + * + * Pure so the decision is testable without Compose — the separation the #90 prototype got right. + * + * Gated on [overlay] deliberately. In the slot layout the Scaffold has RESERVED the bar's space, so + * translating the bar away would leave an empty band rather than handing the space back to content — + * visibly worse than not hiding at all. Auto-hide only makes sense over content. + */ +internal fun shouldHideBar( + autoHide: Boolean, + overlay: Boolean, + scrollingDown: Boolean, + pinned: Boolean = false, +): Boolean = !pinned && autoHide && overlay && scrollingDown + +/** + * #1839: does this scroll delta change the direction, or is it noise? + * + * Returns true for "scrolling down into the page", false for up, and null when the movement is under + * [threshold] and should be ignored — without that, a fingertip tremor flickers the bar continuously. + * A NEGATIVE delta means content moved up, which is the user scrolling down. + */ +internal fun scrollDirectionChange(delta: Float, threshold: Float): Boolean? = when { + delta <= -threshold -> true + delta >= threshold -> false + else -> null +} + +/** + * #1839: the 0..1 collapse fraction the bar's transform rides. + * + * Reduce Motion pins it to 0 — VISIBLE — rather than snapping between hidden and shown. A bar that + * teleports away without animation reads as a glitch, and someone who has asked for less motion is the + * last person who should get that. #90 snapped to 0/1 instead; keeping the bar put is the kinder reading. + */ +internal fun barCollapseFraction(hidden: Boolean, reduceMotion: Boolean): Float = + if (reduceMotion) 0f else if (hidden) 1f else 0f + +/** + * #1836: which bottom-bar layout to use, snapshot-backed so the Settings toggle applies without a + * relaunch (the same shape as `BackgroundImageStore.enabled`). + * + * Default ON as of #1841. It shipped switchable and default-off first so it could be tried without being + * imposed; the overlay was then confirmed on a device. The switch stays, so anyone who dislikes it — or + * hits a screen that misbehaves — can put the reserved-slot layout back. + * + * An explicit choice is preserved either way: `getBoolean(key, true)` returns a stored `false` for someone + * who turned it off, and only an install that never touched the setting picks up the new default. + */ +object BottomBarStyleStore { + /** True = the overlay bar (glass over the screen's own backdrop). False = the reserved slot. */ + var overlay by mutableStateOf(true) + private set + + /** + * The bar's MEASURED height, published so [ScreenScaffold] can clear it. + * + * This is the half that makes the overlay actually do something. Moving the bar out of the slot is not + * enough on its own: a screen's backdrop is painted INSIDE the screen, so while the screen is inset + * above the bar the backdrop stops there too and the glass has nothing behind it but the shell's + * container colour. The screen has to reach the bottom edge, with its scrolling CONTENT clearing the + * bar instead — which is what this height is for. + */ + var barHeight by mutableStateOf(0.dp) + internal set + + /** + * The inset a screen's CONTENT should add, which is the bar height only while the overlay is on. + * A single accessor so callers cannot forget the `overlay` half and inset content in the slot + * layout, where the Scaffold has already reserved that space. + */ + fun barHeightForContent(): Dp = if (overlay) barHeight else 0.dp + + /** + * How see-through the bar's glass is, in EIGHT steps: 1 the most transparent, 8 solid. + * + * A step rather than a raw float so the two ends are reachable and every stop is repeatable - a + * continuous slider on a bar this small mostly produces values a user cannot tell apart or return to. + * The mapping is linear from 0.30 to 1.00, which puts the SHIPPED 0.80 exactly on step 6, so an + * install that never touches this is byte-identical to before. + */ + var opacityStep by mutableStateOf(DEFAULT_OPACITY_STEP) + private set + + /** The alpha for [opacityStep]. Step 1 = 0.30 ... step 6 = 0.80 (the shipped value) ... step 8 = 1.00. */ + val barAlpha: Float get() = alphaForOpacityStep(opacityStep) + + /** + * How much bigger the bar is drawn, from 1x to 2x. + * + * Scales the bar's CONTENT - icon, label and the padding around them - rather than applying a + * graphics scale to the finished bar, which would blur it and leave the touch targets where they + * were. The bar's height is measured and republished either way ([barHeight]), so screens keep + * clearing it correctly at any size without a second number to maintain. + */ + var scale by mutableStateOf(DEFAULT_SCALE) + private set + + /** + * Hold the bar visible regardless of auto-hide, while something is adjusting how it LOOKS. + * + * The transparency slider sits below the fold in Settings, so reaching it means scrolling down - + * which is exactly what auto-hide reads as "hide the bar". The control's live preview was therefore + * invisible at the moment it mattered, and touching the slider does not scroll, so nothing brought + * the bar back. Pinning while the drag is in flight is the narrowest fix: auto-hide is untouched as + * a setting, and the pin lasts only as long as a finger is down. + */ + var previewPinned by mutableStateOf(false) + private set + + /** Named `pinPreview` rather than `setPreviewPinned`: the latter is the property's own generated + * setter, and declaring both is a JVM signature clash. */ + fun pinPreview(value: Boolean) { + previewPinned = value + } + + /** + * Move the bar NOW without persisting, for a slider drag. + * + * Separate from [setOpacityStep] because a drag emits a value per frame: persisting each one would + * write SharedPreferences dozens of times to record a decision the user makes once, on release. The + * card-opacity slider already splits it this way; this is the same split, not a new idea. + */ + fun previewOpacityStep(step: Int) { + opacityStep = step.coerceIn(MIN_OPACITY_STEP, MAX_OPACITY_STEP) + } + + /** Set and persist - for a committed choice, i.e. the end of a drag. */ + fun setOpacityStep(ctx: Context, step: Int) { + val clamped = step.coerceIn(MIN_OPACITY_STEP, MAX_OPACITY_STEP) + opacityStep = clamped + NoopPrefs.of(ctx.applicationContext).edit() + .putInt(NoopPrefs.KEY_BOTTOM_BAR_OPACITY_STEP, clamped).apply() + } + + fun setScale(ctx: Context, value: Float) { + val clamped = nearestScale(value) + scale = clamped + NoopPrefs.of(ctx.applicationContext).edit() + .putFloat(NoopPrefs.KEY_BOTTOM_BAR_SCALE, clamped).apply() + } + + /** #1839: hide the overlay bar while scrolling down, bring it back on scrolling up. Default ON. */ + var autoHide by mutableStateOf(true) + private set + + fun setAutoHide(ctx: Context, value: Boolean) { + autoHide = value + NoopPrefs.of(ctx.applicationContext).edit() + .putBoolean(NoopPrefs.KEY_BOTTOM_BAR_AUTO_HIDE, value).apply() + } + + fun load(ctx: Context) { + val prefs = NoopPrefs.of(ctx.applicationContext) + overlay = prefs.getBoolean(NoopPrefs.KEY_OVERLAY_BOTTOM_BAR, true) + autoHide = prefs.getBoolean(NoopPrefs.KEY_BOTTOM_BAR_AUTO_HIDE, true) + // Both are read through the same clamps the setters use, so a hand-edited or downgraded pref + // cannot put the bar in a state the UI has no way to leave. + opacityStep = prefs.getInt(NoopPrefs.KEY_BOTTOM_BAR_OPACITY_STEP, DEFAULT_OPACITY_STEP) + .coerceIn(MIN_OPACITY_STEP, MAX_OPACITY_STEP) + scale = nearestScale(prefs.getFloat(NoopPrefs.KEY_BOTTOM_BAR_SCALE, DEFAULT_SCALE)) + } + + fun set(ctx: Context, value: Boolean) { + overlay = value + NoopPrefs.of(ctx.applicationContext).edit() + .putBoolean(NoopPrefs.KEY_OVERLAY_BOTTOM_BAR, value).apply() + } +} + /** * App shell: a single [Scaffold] with a floating [GlassBottomBar] (Today · Trends · Sleep · More) * driving one [NavHost], mirroring the iOS RootTabView. There is NO global toolbar and no nav drawer @@ -290,7 +477,54 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) { // survives the inbox sheet closing — the tap dismisses the inbox and presents this over the app. var showWhatsNewFromInbox by remember { mutableStateOf(false) } - run { + // #1836: an overlay container, not a bottomBar slot. The slot sat OUTSIDE the screen content, so a + // screen's own backdrop (LiquidScreenSky / BackgroundImageBackdrop) stopped where the bar began and + // the bar's 0.80 "glass" had nothing behind it but surfaceBase — a bar built to float rendered as a + // dark strip cut out of the background. As a sibling drawn OVER the Scaffold it sits on the screen's + // own sky, which is what the translucency was written for. + // #1836: the inset MEASURED, never assumed. The bar's height includes navigationBarsPadding(), which + // differs by roughly 24dp between gesture navigation and 3-button navigation, and changes on rotation + // and on a foldable unfolding. The Scaffold slot used to measure it for us; a constant here would put + // content behind the bar on exactly the devices the report came from. One extra layout pass at + // startup, then stable. + var barHeightPx by remember { mutableIntStateOf(0) } + val density = LocalDensity.current + val barHeight = with(density) { barHeightPx.toDp() } + // #1839: auto-hide. ONE NestedScrollConnection on the shell means every screen gets the behaviour with + // no per-screen wiring — scrollable children dispatch their deltas up to it. onPreScroll only flips a + // Boolean, and only on a movement past the threshold, so a fingertip tremor cannot flicker the bar. + var scrollingDown by remember { mutableStateOf(false) } + val reduceMotion = rememberReduceMotion() + val hidden = shouldHideBar(BottomBarStyleStore.autoHide, BottomBarStyleStore.overlay, scrollingDown, + pinned = BottomBarStyleStore.previewPinned) + val collapseTarget = barCollapseFraction(hidden, reduceMotion) + // The transform is a graphicsLayer only — GPU, per frame, NO relayout — so content never reflows as + // the bar comes and goes and scrolling stays smooth. (The approach #90 got right.) + // Held as the State, not unwrapped with `by`, ON PURPOSE. Reading a `Float` in composition would + // recompose this whole shell — Scaffold and NavHost included — on EVERY animation frame, which is the + // exact jank the graphicsLayer-only approach exists to avoid. Instead: + // - the transform reads `.value` INSIDE graphicsLayer, a deferred read that updates the layer with + // no recomposition at all; + // - presence is a derivedStateOf, so composition is invalidated once when the bar appears or + // disappears, not sixty times a second while it moves. + val collapseState = animateFloatAsState( + targetValue = collapseTarget, + animationSpec = tween(durationMillis = 220), + ) + val barPresent by remember { derivedStateOf { collapseState.value < 1f } } + // Landing on a new screen with no visible way to navigate is disorienting, and the bar cannot be + // tapped to fix it because it is the thing that is hidden. Reset on every route change so a screen + // always opens with its navigation present; scrolling down again hides it as before. + LaunchedEffect(currentRoute) { scrollingDown = false } + val autoHideScroll = remember { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + scrollDirectionChange(available.y, threshold = 3f)?.let { scrollingDown = it } + return Offset.Zero + } + } + } + Box(Modifier.fillMaxSize().nestedScroll(autoHideScroll)) { Scaffold( containerColor = Palette.surfaceBase, bottomBar = { @@ -299,18 +533,42 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) { // top-right (balancing the avatar), so the bar is clean tabs only. "More" navigates to // its own page (mirroring the iOS More tab) that reaches every grouped destination, so no // destination is lost without the drawer. - GlassBottomBar( - current = current, - onTabSelected = { dest -> - if (dest.route != currentRoute) nav.navigateTopLevel(dest.route) - }, - ) + // DEFAULT path: the shipped reserved slot, unchanged. Empty only when the overlay is + // on, where the bar is drawn below as a sibling and the slot must reserve nothing. + if (!BottomBarStyleStore.overlay) { + GlassBottomBar( + current = current, + onTabSelected = { dest -> + if (dest.route != currentRoute) nav.navigateTopLevel(dest.route) + }, + ) + } }, ) { inner -> NavHost( navController = nav, startDestination = Destination.Today.route, - modifier = Modifier.padding(inner), + // The empty slot reserves nothing, so the bar's height is added here — the one place the + // inset used to come from. A scrollable that later wants content to pass UNDER the glass + // adds this measured height to its own contentPadding instead; no shared modifier can. + // Slot layout: the Scaffold measured the bar into `inner`, so use it as-is. + // Overlay layout: the slot reserves nothing, so the bottom comes from the measured bar. + modifier = if (!BottomBarStyleStore.overlay) Modifier.padding(inner) else Modifier.padding( + // Take the top and sides from the Scaffold, but REPLACE its bottom rather than adding + // to it. Scaffold's default contentWindowInsets is WindowInsets.systemBars, so + // `inner.bottom` already carries the navigation-bar inset — and the bar's measured + // height carries it too, via its own navigationBarsPadding(). Adding both counted that + // inset twice and left roughly a nav-bar's worth of dead space above the bar, widest + // on 3-button navigation. The bar owns that inset; content just clears the bar. + top = inner.calculateTopPadding(), + start = inner.calculateStartPadding(LocalLayoutDirection.current), + end = inner.calculateEndPadding(LocalLayoutDirection.current), + // Bottom is ZERO on purpose: the screen must reach the bottom edge so its own backdrop + // paints behind the glass. ScreenScaffold clears the bar from the scrolling CONTENT + // instead, using BottomBarStyleStore.barHeight. Insetting here is what made the first + // version of this change invisible — the bar moved, the backdrop did not follow. + bottom = 0.dp, + ), // README motion: top-level destinations crossfade (~240ms) on the calm, // decelerating global easing — nothing slides or bounces between tabs. The // same fade is used for back (pop) so the bar never feels jerky. Drill-ins @@ -351,6 +609,9 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) { onOpenSleep = { nav.navigateTopLevel(Destination.Sleep.route) }, // Optional Coupled view card (task #43): a normal push so back returns to Today. onOpenCoupled = { nav.navigate(Destination.CoupledView.route) }, + // #1862: the Coach launcher hands off here. Without this the sheet's buttons + // would fall back to the parameter's no-op default and silently do nothing. + onOpenCoach = { nav.navigateTopLevel(Destination.Coach.route) }, // The "workout in progress" indicator: raise the one-shot the Live screen consumes to // re-open the in-exercise overlay, then route to Live. One tap from Today (iOS parity). onOpenActiveWorkout = { @@ -452,6 +713,7 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) { viewModel, onOpenTestCentre = { nav.navigate(Destination.TestCentre.route) }, onOpenBackupSync = { nav.navigate(Destination.BackupSync.route) }, + onOpenSelfHostedPush = { nav.navigate(Destination.SelfHostedPush.route) }, onOpenStepsCalibration = { nav.navigate(Destination.StepsCalibration.route) }, ) } @@ -468,7 +730,13 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) { onClose = { nav.popBackStack() }, ) } - composable(Destination.TestCentre.route) { TestCentreScreen(viewModel) } + composable(Destination.SelfHostedPush.route) { SelfHostedPushScreen() } + composable(Destination.TestCentre.route) { + TestCentreScreen(viewModel, onOpenGroundTruthCollector = { + nav.navigate(Destination.GroundTruthCollector.route) + }) + } + composable(Destination.GroundTruthCollector.route) { GroundTruthCollectorScreen(viewModel) } // The "More" page — the iOS More tab's twin: a navigated ScreenScaffold page hosting the // full grouped destination list (was a pull-up sheet). A row pushes its destination so // Android Back returns to More instead of skipping straight to Today. @@ -603,6 +871,32 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) { } } } + + // Drawn OVER the Scaffold, so it floats on whatever backdrop the current screen painted rather + // than on the shell's own container colour. Same composable, same insets — only its parent moved. + // `collapse < 1f` and not just `overlay`: at alpha 0 the bar is invisible but still COMPOSED, so + // TalkBack could focus a bar nobody can see and a tap could land on a control that is not there. + // Dropping it at the end of the animation removes both. Safe precisely because it is an overlay — + // it reserves no space, so composing or not composing it never reflows content. + if (BottomBarStyleStore.overlay && barPresent) GlassBottomBar( + current = current, + onTabSelected = { dest -> + if (dest.route != currentRoute) nav.navigateTopLevel(dest.route) + }, + modifier = Modifier + .align(Alignment.BottomCenter) + .graphicsLayer { + // Slide it out past its own height so the whole capsule clears the edge, and fade so + // it does not read as a bar stuck half off-screen mid-animation. + val c = collapseState.value // deferred read: layer only, no recomposition + translationY = c * (barHeightPx.toFloat()) + alpha = 1f - c + } + .onSizeChanged { + barHeightPx = it.height + BottomBarStyleStore.barHeight = with(density) { it.height.toDp() } + }, + ) } } @@ -766,10 +1060,11 @@ private val barTrailingTabs = listOf( private fun GlassBottomBar( current: Destination, onTabSelected: (Destination) -> Unit, + modifier: Modifier = Modifier, ) { val barShape = RoundedCornerShape(50) Box( - modifier = Modifier + modifier = modifier .fillMaxWidth() // Clear the gesture-nav bar (home indicator) first, then add breathing room so the capsule // floats free of the bottom edge rather than jamming against it — iOS clears the home-indicator @@ -784,7 +1079,9 @@ private fun GlassBottomBar( // "Glass": a translucent raised surface — a frosted island, not a hard slab. Compose has no // cheap blur, so translucency (≈0.80) + a hairline rim is the Liquid-Glass stand-in. A soft, // low drop shadow reads as floating without a glow. - color = Palette.surfaceRaised.copy(alpha = 0.80f), + // The glass alpha is the user's transparency step; 0.80 was the shipped constant and remains + // the default (step 6), so an untouched install is unchanged. + color = Palette.surfaceRaised.copy(alpha = BottomBarStyleStore.barAlpha), tonalElevation = 2.dp, shadowElevation = 4.dp, modifier = Modifier @@ -796,7 +1093,11 @@ private fun GlassBottomBar( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 7.dp), + // Scaling the PADDING and the slot contents grows the bar honestly - the touch + // targets grow with it, and `barHeight` is measured afterwards so screens keep + // clearing the bar at any size. A graphics scale would blur it and leave the hit + // areas behind. + .padding(horizontal = 8.dp, vertical = 7.dp * BottomBarStyleStore.scale), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp), ) { @@ -853,16 +1154,19 @@ private fun BarSlot( indication = null, onClick = onClick, ) - .padding(vertical = 3.dp) + .padding(vertical = 3.dp * BottomBarStyleStore.scale) .semantics { contentDescription = label }, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(3.dp), ) { - Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(Metrics.iconSmall)) + // Icon and label scale together with the padding above, so the slot grows as one piece rather + // than a bigger box around the same small glyph. + Icon(icon, contentDescription = null, tint = tint, + modifier = Modifier.size(Metrics.iconSmall * BottomBarStyleStore.scale)) Text( label, style = NoopType.footnote.copy( - fontSize = 10.sp, + fontSize = 10.sp * BottomBarStyleStore.scale, fontWeight = if (active) FontWeight.SemiBold else FontWeight.Medium, ), color = tint, diff --git a/android/app/src/main/java/com/noop/ui/AppViewModel.kt b/android/app/src/main/java/com/noop/ui/AppViewModel.kt index a1f8dbdbfa..249c618903 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -13,6 +13,7 @@ import com.noop.analytics.Baselines import com.noop.analytics.IllnessSignalEngine import com.noop.analytics.IllnessWatch import com.noop.analytics.IntelligenceEngine +import com.noop.analytics.DayCycleIntelligenceIntegration import com.noop.analytics.CircadianEngine import com.noop.analytics.V5HealthSignals import com.noop.analytics.RegistryDayOwnerSource @@ -52,6 +53,7 @@ import com.noop.protocol.CommandNumber import com.noop.widget.WidgetSnapshot import com.noop.widget.WidgetSnapshotStore import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.withTimeoutOrNull @@ -61,6 +63,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.isActive @@ -254,12 +259,18 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { noopApp.deviceRegistry.rename(id, nickname) /** Permanently delete all of a device's recorded data (its registry row is kept). */ - suspend fun deletePairedDeviceData(id: String) = noopApp.deviceRegistry.deleteDeviceData(id) + suspend fun deletePairedDeviceData(id: String) { + if (com.noop.testcentre.ImuSessionFileStore(getApplication()).deleteDevice(id)) + noopApp.deviceRegistry.deleteDeviceData(id) + } /** Permanently forget a device: wipe all its recorded data AND remove its registry entry, so a * duplicate/stale strap disappears from the list entirely (an archived row could otherwise only be * re-activated or data-wiped, never purged — #1193). Twin of Swift `DeviceRegistry.forget`. */ - suspend fun forgetPairedDevice(id: String) = noopApp.deviceRegistry.forget(id) + suspend fun forgetPairedDevice(id: String) { + if (com.noop.testcentre.ImuSessionFileStore(getApplication()).deleteDevice(id)) + noopApp.deviceRegistry.forget(id) + } /** * A DISCOVERY-ONLY [StandardHrSource] for the Add-a-strap wizard. It runs its OWN scan and never @@ -272,7 +283,8 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { context = appContext, deviceId = "scan-preview", liveSink = { _, _ -> }, - persist = { _, _ -> }, + // Discovery-only scanner: nothing is persisted, so the outcome callback never fires. + persist = { _, _, _ -> }, // Route the throwaway scanner's diagnostics into the SAME exported strap log the active path // uses (issue #421 parity), so a tester's wizard scan is captured. The source self-prefixes // "HR-strap: "; [externalLog] redacts addresses. Privacy-safe: statuses / counts only. @@ -432,16 +444,38 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { * recordings under a read pinned to the literal "my-whoop" (#814 twin of the Workouts screen). */ val deviceId = noopApp.activeDeviceId - /** Last (bins, daysObserved) computed on the collector pass. Reused by the SYNCHRONOUS settings - * re-evaluate below, which has no coroutine to read the store from — without this, flipping the + /** Last (bins, daysObserved) computed on the collector pass. Reused by the cycle-tracking + * re-evaluates below so they need no store read of their own — without this, flipping the * cycle-tracking toggle would blank the body clock until the next collector tick. * + * Those sites now go through [freshCircadianBins], which reads the store ONLY when this cache is + * empty, and they sit inside `viewModelScope.launch`. On `Dispatchers.Main.immediate` a body that + * never suspends runs inline, so the populated-cache path is as immediate as the plain call it + * replaced; only the empty-cache case actually defers. (This comment previously said the + * re-evaluate was SYNCHRONOUS and had no coroutine — true before that change, not after.) + * * Not volatile and not synchronised, deliberately: the write resumes on `viewModelScope` * (Dispatchers.Main.immediate) and the read is a UI-thread settings callback, so both touch it on * the main thread. The Swift twin gets the same guarantee from `@MainActor` on AppModel. */ private var lastCircadianBins: Pair, Int> = emptyList() to 0 + /** + * The circadian bins, re-read first if the cache is empty. + * + * Only the main collect path refreshed [lastCircadianBins]; the cycle-tracking paths reused whatever + * was cached, which starts as `emptyList() to 0` and returns to empty whenever a store read fails + * (`circadianActivityBins` swallows that to an empty list). Either way a cycle re-evaluation would + * then hand `V5HealthSignals` no bins, drop below its six-bin floor, and null out `bodyClock` — the + * Health card disappearing on a device whose data is fine, which is exactly the symptom that started + * this. Refreshing only when EMPTY keeps the cheap path cheap: a populated cache is left alone and + * the collector stays the thing that keeps it current. + */ + private suspend fun freshCircadianBins(): Pair, Int> { + if (lastCircadianBins.first.isEmpty()) lastCircadianBins = circadianActivityBins() + return lastCircadianBins + } + /** * Per-hour activity profile for the body clock (#852), from the last ~14 days of hourly HR buckets. * HR amplitude is a usable rest/activity rhythm proxy when raw motion is not to hand. @@ -605,6 +639,11 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { private val _phoneAlarmEnabled = MutableStateFlow(phoneAlarmStore.enabled) /** Whether the phone smart alarm is armed (a guaranteed OS alarm is scheduled). */ val phoneAlarmEnabled: StateFlow = _phoneAlarmEnabled.asStateFlow() + /** #1858: per-weekday wake-time overrides for the PHONE alarm — the strap alarm's #554 feature, + * which this screen has shown beside it since then. Empty = every enabled day uses the single time. */ + private val _phoneAlarmDayOverrides = MutableStateFlow(phoneAlarmStore.targetOverrides) + val phoneAlarmDayOverrides: StateFlow> = _phoneAlarmDayOverrides.asStateFlow() + private val _phoneAlarmTargetMinutes = MutableStateFlow(phoneAlarmStore.targetMinutes) /** Earliest acceptable wake time, minutes since midnight. */ val phoneAlarmTargetMinutes: StateFlow = _phoneAlarmTargetMinutes.asStateFlow() @@ -684,6 +723,33 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { repository.recentDaysMergedFlow(deviceId) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + /** + * Today's measured steps follow the newest confirmed sleep-onset cycle, independently of the fixed + * 04:00 presentation day used by the rest of the dashboard. The marker is persisted by the analytics + * pass, so this survives process death and a delayed sleep detection can switch the tile retroactively. + */ + @OptIn(ExperimentalCoroutinesApi::class) + internal val activeDayCycle: StateFlow = activeStrapIdFlow + .map { effectiveActiveStrapId(it, deviceId) } + .distinctUntilChanged() + .flatMapLatest { activeId -> + repository.recentDaysMergedFlow(activeId).flatMapLatest days@{ days -> + if (days.isEmpty()) return@days flowOf(null) + val from = days.first().day + val to = days.last().day + combine( + repository.computedDailyUnionFlow(activeId, from, to), + repository.metricSeriesComputedUnionFlow( + activeId, DayCycleIntelligenceIntegration.ONSET_KEY, from, to, + ), + ) { computed, markers -> + resolveActiveDayCycle(days, computed, markers, System.currentTimeMillis() / 1_000L) + } + } + } + .flowOn(Dispatchers.IO) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + /** * #103: SpO₂ candidate @82 nightly mean per day, loaded from the "spo2_candidate" metricSeries * key (written by IntelligenceEngine when the experimental display toggle is ON). Empty when the @@ -811,6 +877,46 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { ble.connectedPeripheralAddress .collect { addr -> noopApp.sourceCoordinator.connectedPeripheralChanged(addr) } } + // #1303: the 5/MG DIS read hands up the strap's OWN serial, so re-point this pairing from its + // transient address-based id onto a stable `whoop-` id, through the SAME migration the ring + // already uses (#771) — a re-pair or factory reset then stops forking one physical strap into a + // second row and orphaning its history (#1193). Lives here rather than in SourceCoordinator, which + // is deliberately inert on the WHOOP path and never touches WhoopBleClient internals; this class + // already owns the registry handle and a scope. Twin of Swift `BLEManager.adoptWhoopSerialIdentity`. + // + // A WHOOP 4.0 reaches here by a different road: it exposes no DIS serial, so its serial is decoded + // from the GET_HELLO_HARVARD response (`Whoop4HelloSerial`) and handed to this same callback only + // after a SECOND sighting of the same value — see `WhoopBleClient.noteHarvardSerial` for why a 4.0 + // waits where a 5/MG adopts on the first read (#1193). + ble.onSerial = { serial -> + viewModelScope.launch { + val serialId = com.noop.data.WhoopSerialIdentity.adoptedId(serial) + val currentId = deviceRegistry.activeDeviceId() + // Idempotent: once adopted the id already equals the serial id, so a reconnect costs one + // string compare and no database work. A serial WhoopSerialIdentity refuses (blank, + // truncated, non-serial prose) leaves the strap on its existing id — adopting onto a junk id + // would migrate every device-scoped row onto a garbage key, worse than not adopting at all. + if (serialId != null && currentId != null && currentId != serialId && + com.noop.data.WhoopSerialIdentity.mayAdopt(currentId) && + deviceRegistry.adoptSerialIdentity(currentId, serialId) + ) { + // Prefix only. `serialId` embeds the full serial, which must never reach a shared log. + ble.logIdentity( + "WHOOP: adopted stable serial identity (serialPrefix=" + + com.noop.data.WhoopSerialIdentity.logSafe(serial) + + ") - history re-pointed off the transient pairing id (#1303)", + ) + deviceRegistry.setActive(serialId) + // The process-wide handle too, not just the registry and the coordinator. It is read + // by the diagnostics export and threaded into the BLE client and the source + // coordinator at startup; leaving it behind meant the engine kept deriving days under + // the PRE-adoption id until the next cold start, splitting the computed history across + // both ids (field-confirmed on a 5/MG before this line existed). + noopApp.onActiveDeviceAdopted(serialId) + noopApp.sourceCoordinator.onActiveDeviceChanged(serialId) + } + } + } // Re-arm the strap's firmware alarm once per process-alive day. The firmware alarm is a single // absolute instant with NO recurrence and was previously re-armed ONLY on the bond edge — so a // strap that stays continuously bonded (a phone in range overnight) would fire once and then @@ -998,10 +1104,10 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { }.onFailure { if (it is kotlin.coroutines.cancellation.CancellationException) throw it } // #836 parity (Android): the 15-min tick is a backstop, not a data-driven refresh. Every real // update (sync, import, edit, recalibrate, the #547 heal above) rescores via its own path and - // moves the raw-HR fingerprint, so skip the heavy 21-day rescore when the HR stream is unchanged + // moves the complete raw-input fingerprint, so skip the heavy 21-day rescore when every scoring stream is unchanged // since the last COMPLETED run. Mirrors the Swift analyzeRecent(force:false) gate; the watermark // advances only on success (below), so an interrupted run can never hide unscored data. - val analyzeFp = repository.hrFingerprint() + val analyzeFp = repository.analysisFingerprint() // #1538: attribute the tick that is about to run. An idle-tick pass previously emitted NO // trigger line at all — a "re-score: done" with nothing before it — so a strap log could not // be read by pairing trigger->done, and a stalled background pass was easy to misattribute to @@ -1013,6 +1119,11 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { val analyzeHasNewData = analyzeFp != NoopPrefs.analyzeWatermark(appContext) if (analyzeHasNewData) ble.externalLog("re-score: trigger=idle newData=yes") if (analyzeHasNewData) runCatching { + // #1816: set the motion sink before the pass so the Today tile can name the right + // missing half (motion, not phone steps) when none has arrived yet. Cleared after. + IntelligenceEngine.stepsHasMotionSink = { hasMotion -> + profileStore.stepsHasBankedMotion = hasMotion + } IntelligenceEngine.analyzeRecent( repo = repository, profile = currentProfile(), @@ -1115,12 +1226,26 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { // persists the nightly @82 mean as "spo2_candidate" in metricSeries. spo2CandidateDisplay = NoopPrefs.spo2CandidateDisplay(appContext), effortMethod = NoopPrefs.effortMethod(appContext), + dayCycleMode = NoopPrefs.dayCycleMode(appContext), ) // analyzeRecent now hops to Dispatchers.Default; a scope cancellation surfaces as a // CancellationException that runCatching would otherwise swallow, breaking the loop's // own cancellation — rethrow it so onCleared() actually stops the loop. (#125) - }.onSuccess { NoopPrefs.setAnalyzeWatermark(appContext, analyzeFp) } + }.onSuccess { + NoopPrefs.setAnalyzeWatermark(appContext, analyzeFp) + // #1735: the watermark says WHAT was scored, never WHEN. "re-score: done" goes only to + // the live log, so hours later the rolling buffer has dropped it and an export cannot + // tell a pass that ran from one that never did - which is half of "my ride still is not + // showing". Stamp the completion; AndroidDiagnostics renders it beside "Data write". + runCatching { + NoopPrefs.of(appContext).edit() + .putLong("score.lastPassAt", System.currentTimeMillis() / 1000).apply() + } + } .onFailure { if (it is kotlin.coroutines.cancellation.CancellationException) throw it } + // #1816: clear the motion sink after the pass completes (success or failure) so a stale + // callback is never left behind. The next pass sets its own before calling analyzeRecent. + IntelligenceEngine.stepsHasMotionSink = null // Opt-in writeback: push the freshly computed nights into Health Connect so other // apps see them. Idempotent (clientRecordId per metric+day), so re-running every // cycle just upserts. Never let an HC hiccup (perm revoked mid-flight, provider @@ -1713,6 +1838,10 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { */ private suspend fun rescoreAfterEdit() { runCatching { + // #1816: set the motion sink before the pass, clear it after (same pattern as the 15-min loop). + IntelligenceEngine.stepsHasMotionSink = { hasMotion -> + profileStore.stepsHasBankedMotion = hasMotion + } IntelligenceEngine.analyzeRecent( repo = repository, profile = currentProfile(), @@ -1743,11 +1872,11 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { // #103: SpO₂ candidate @82 display toggle — same flag the 15-min loop reads. spo2CandidateDisplay = NoopPrefs.spo2CandidateDisplay(appContext), effortMethod = NoopPrefs.effortMethod(appContext), + dayCycleMode = NoopPrefs.dayCycleMode(appContext), ) }.onFailure { if (it is kotlin.coroutines.cancellation.CancellationException) throw it } + IntelligenceEngine.stepsHasMotionSink = null } - - /** Re-read every source + the dismissed markers and republish [workouts]. */ fun loadWorkouts() { viewModelScope.launch { val now = System.currentTimeMillis() / 1000 @@ -2096,6 +2225,17 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { fun clearBodyLocationProbe() = ble.clearBodyLocationProbe() + /** Read-only battery-pack probe (cmd 151) — asks the strap for its pack's charge, serial and BT + * address. User-initiated, Test-Centre-gated in DevicesScreen; the decoded report goes to the + * dialog + strap log only, and feeds no live state. 5/MG only in practice (a 4.0 has no pack + * command), and whether a 5/MG answers at all is the hardware question being asked. */ + fun probeBatteryPackInfo() = ble.probeBatteryPackInfo() + + /** cmd-151 probe result text (null until a reply lands; waiting sentinel while in flight). */ + val batteryPackProbe = ble.batteryPackProbe + + fun clearBatteryPackProbe() = ble.clearBatteryPackProbe() + /** #761: READ-ONLY feature-flag ENUMERATION probe (117 then repeated 118) — reads the flag NAMES the * strap's own firmware knows and writes nothing (no SET_FF_VALUE, no value of any kind). * User-initiated, Test-Centre-gated in DevicesScreen; the report goes to the dialog + strap log. */ @@ -2330,8 +2470,11 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { val granted = runCatching { HealthConnectImporter.client(appContext).permissionController.getGrantedPermissions() }.getOrDefault(emptySet()) + val selectedPermissions = HealthConnectImporter.permissionsFor( + HealthConnectImporter.selectedCategories(appContext), + ) // Partial permissions are fine (#150): auto-import as long as at least one type is granted. - if (granted.none { it in HealthConnectImporter.PERMISSIONS }) return@withContext false + if (granted.none { it in selectedPermissions }) return@withContext false // Pass the profile height so the importer can derive BMI (Health Connect has no BMI record). runCatching { HealthConnectImporter.import(appContext, repository, profileStore.heightCm) }.isSuccess } @@ -2453,12 +2596,43 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { _phoneAlarmTargetMinutes.value = phoneAlarmStore.targetMinutes if (phoneAlarmStore.enabled) SmartAlarmScheduler.arm(appContext, phoneAlarmStore) // The wind-down nudge is derived from the wake time, so keep it in step. - if (windDownStore.enabled) WindDownScheduler.schedule(appContext, windDownStore, phoneAlarmStore.targetMinutes) + if (windDownStore.enabled) WindDownScheduler.schedule( + appContext, windDownStore, phoneAlarmStore.targetMinutes, phoneAlarmStore.targetOverrides, + ) // #536: re-arm the strap at the new earliest time when "Buzz WHOOP 4" is on. Routed through the // single reconciler so it can't clobber a smart-alarm the user still has on (#5). reconcileStrapAlarm() } + /** + * #1858: set or clear ONE weekday's wake-time override for the phone alarm. + * + * `null` clears, so a day can go back to following the single time without a second control. Re-arms + * afterwards for the same reason every other alarm edit does: the scheduled deadline is computed from + * these values, so leaving it stale would keep waking the user at the old time until something else + * happened to re-arm. Mirrors `setSmartAlarmDayOverride` for the strap. + */ + fun setPhoneAlarmDayOverride(dayOfWeek: Int, minutes: Int?) { + val next = phoneAlarmStore.targetOverrides.toMutableMap() + if (minutes == null) next.remove(dayOfWeek) else next[dayOfWeek] = minutes + phoneAlarmStore.targetOverrides = next + _phoneAlarmDayOverrides.value = phoneAlarmStore.targetOverrides + if (phoneAlarmStore.enabled) SmartAlarmScheduler.arm(appContext, phoneAlarmStore) + // The strap-buzz companion derives its buzz time FROM the phone alarm, so an override that moves a + // wake time has to move the buzz with it. `setPhoneAlarmWeekdays` already reconciles for exactly + // this reason; omitting it here would leave the strap armed for the old time until some unrelated + // edit happened to reconcile — a strap buzzing at the wrong hour being worse than one not buzzing. + reconcileStrapAlarm() + // The wind-down nudge is anchored to the wake time this just moved, so it has to move too — + // Apple's WindDownNudge has fanned out per-day since #554. Without this the reminder keeps + // pointing at the old wake on exactly the day the user changed. + if (windDownStore.enabled) { + WindDownScheduler.schedule( + appContext, windDownStore, phoneAlarmStore.targetMinutes, phoneAlarmStore.targetOverrides, + ) + } + } + /** Set the days the phone alarm fires on. EMPTY = every day (see [SmartAlarmStore.weekdays]). * * Re-arms while enabled so deselecting today's day moves the alarm to the next selected one @@ -2497,7 +2671,9 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { fun setWindDownEnabled(enabled: Boolean) { windDownStore.enabled = enabled _windDownEnabled.value = enabled - if (enabled) WindDownScheduler.schedule(appContext, windDownStore, phoneAlarmStore.targetMinutes) + if (enabled) WindDownScheduler.schedule( + appContext, windDownStore, phoneAlarmStore.targetMinutes, phoneAlarmStore.targetOverrides, + ) else WindDownScheduler.cancel(appContext) } @@ -2524,16 +2700,21 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { fun setCycleTrackingEnabled(enabled: Boolean) { _cycleTrackingEnabled.value = enabled NoopPrefs.setCycleTracking(appContext, enabled) - val days = recentDays.value - runCatching { - _v5Signals.value = V5HealthSignals.evaluate( - days = days, - cycleOptedIn = enabled, - loggedPeriodStarts = _periodStarts.value, - journalContext = illnessJournalContext(days), - activityBins = lastCircadianBins.first, - daysObserved = lastCircadianBins.second, - ) + // Launched, like the sibling toggles, because the bins may need a store read before this snapshot + // is safe to publish — evaluating straight off an empty cache is what erased `bodyClock`. + viewModelScope.launch { + val days = recentDays.value + val bins = freshCircadianBins() + runCatching { + _v5Signals.value = V5HealthSignals.evaluate( + days = days, + cycleOptedIn = enabled, + loggedPeriodStarts = _periodStarts.value, + journalContext = illnessJournalContext(days), + activityBins = bins.first, + daysObserved = bins.second, + ) + } } } @@ -2554,6 +2735,12 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { viewModelScope.launch { rescoreAfterEdit() } } + /** Change the shared boundary used by additive daily metrics and immediately rebuild affected rows. */ + fun setDayCycleMode(mode: com.noop.analytics.DayCycleMode) { + NoopPrefs.setDayCycleMode(appContext, mode) + viewModelScope.launch { rescoreAfterEdit() } + } + /** Log one local day as cycle day 1, then immediately re-run the classifier with the new anchor. */ fun logPeriodStart(day: String = CycleTrackingStore.todayKey()) { viewModelScope.launch { @@ -2585,13 +2772,14 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { val starts = store.starts() _periodStarts.value = starts val days = recentDays.value + val bins = freshCircadianBins() _v5Signals.value = V5HealthSignals.evaluate( days = days, cycleOptedIn = _cycleTrackingEnabled.value, loggedPeriodStarts = starts, journalContext = illnessJournalContext(days), - activityBins = lastCircadianBins.first, - daysObserved = lastCircadianBins.second, + activityBins = bins.first, + daysObserved = bins.second, ) } @@ -2655,12 +2843,20 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { } else null // Buzz-WHOOP-4 companion's requested time: the phone alarm's EARLIEST wake time, next occurrence // ON A DAY THAT ALARM ACTUALLY FIRES. This routes through the same weekday-aware resolver the - // smart alarm uses (with no per-day overrides — the phone alarm has none) rather than - // nextDailyEpochSec, which is unconditionally daily: leaving it daily would buzz the strap on a - // morning the phone alarm is switched off, which is precisely the day the user asked to sleep in. - // An empty weekday set still means every day, so the default behaviour is unchanged. + // smart alarm uses rather than nextDailyEpochSec, which is unconditionally daily: leaving it daily + // would buzz the strap on a morning the phone alarm is switched off, which is precisely the day the + // user asked to sleep in. An empty weekday set still means every day. + // + // #1858: the per-day overrides go through TOO. This companion exists to buzz the strap at the phone + // alarm's earliest wake time, so a day whose wake time was moved must move the buzz with it — the + // comment here used to say "the phone alarm has none", which stopped being true the moment it got + // them, and a strap buzzing at the old time is worse than one not buzzing at all. val buzzEpoch = if (_buzzWhoop4Enabled.value) { - nextSmartAlarmEpochSec(phoneAlarmStore.targetMinutes, phoneAlarmStore.weekdays) + nextSmartAlarmEpochSec( + phoneAlarmStore.targetMinutes, + phoneAlarmStore.weekdays, + dayOverrides = phoneAlarmStore.targetOverrides, + ) } else null val epochSec = earliestStrapAlarmEpochSec(smartEpoch, buzzEpoch) @@ -2992,9 +3188,14 @@ internal fun earliestStrapAlarmEpochSec(smartEpoch: Long?, buzzEpoch: Long?): Lo /** * Elapsed-workout clock from a whole-second count: M:SS up to an hour, H:MM:SS once an hour has passed (so a - * 90-minute session reads "1:30:00", not "90:00"). Negative inputs clamp to zero ("0:00"). Pure so the - * Today "workout in progress" indicator and the Live card share ONE format, and the iOS-parity H:MM:SS - * roll-over is unit-testable without composing any UI. Mirrors iOS ActiveWorkoutIndicatorModel.elapsed. + * 90-minute session reads "1:30:00", not "90:00"). Negative inputs clamp to zero ("0:00"). Pure so every + * surface showing an in-flight workout — the Today card, the Live card, the Workouts-tab banner and the + * full-screen live screen — shares ONE format, and the iOS-parity H:MM:SS roll-over is unit-testable + * without composing any UI. + * + * Twin of Swift `ActiveWorkoutClock.clock`. (It used to name ActiveWorkoutIndicatorModel.elapsed, which + * no longer owns the formatting — that now delegates, like everything else, so the reference moved with + * the code rather than being left pointing at a caller.) */ internal fun elapsedClock(elapsedS: Long): String { val total = elapsedS.coerceAtLeast(0) diff --git a/android/app/src/main/java/com/noop/ui/AutomationsScreen.kt b/android/app/src/main/java/com/noop/ui/AutomationsScreen.kt index 7df721ed99..401beb9e92 100644 --- a/android/app/src/main/java/com/noop/ui/AutomationsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/AutomationsScreen.kt @@ -56,6 +56,7 @@ import com.noop.analytics.HrZones import com.noop.analytics.NapCandidate import kotlinx.coroutines.launch import kotlin.math.roundToInt +import com.noop.analytics.ClockFormat /** * Automations — turn the strap's physical inputs (double-tap, wrist on/off) and live @@ -458,7 +459,10 @@ private fun NapActionButton(icon: ImageVector, contentDescription: String, tint: /** "HH:mm–HH:mm · ~N min", local time. Pure-ish (reads the device clock format only). */ private fun napWindowLabel(nap: NapCandidate, ctx: android.content.Context): String { - val fmt = android.text.format.DateFormat.getTimeFormat(ctx) + // #1821: the reader's chosen clock, not the raw device format. + val fmt = java.text.SimpleDateFormat( + ClockFormat.hourMinutePattern(ClockPrefs.uses24Hour(ctx)), java.util.Locale.getDefault(), + ) val start = fmt.format(java.util.Date(nap.start * 1000L)) val end = fmt.format(java.util.Date(nap.end * 1000L)) val mins = nap.durationS / 60 diff --git a/android/app/src/main/java/com/noop/ui/BackupSyncScreen.kt b/android/app/src/main/java/com/noop/ui/BackupSyncScreen.kt index 9a0fcbeeba..f173214299 100644 --- a/android/app/src/main/java/com/noop/ui/BackupSyncScreen.kt +++ b/android/app/src/main/java/com/noop/ui/BackupSyncScreen.kt @@ -86,32 +86,49 @@ fun BackupSyncScreen() { fun runRestore(uri: Uri) { busy = true scope.launch { - val r = withContext(Dispatchers.IO) { DataBackup.importFrom(context, uri) } - busy = false - when (r) { - is DataBackup.ImportResult.NeedsRestart -> { - // #57: the restore CLOSED and swapped the database file. The long-lived WhoopRepository + - // BLE client still hold a DAO on the OLD (now-closed) connection, so any strap sync would - // fail with "connection pool has been closed" — and, worse, empty/metadata history ENDs - // would still ack and trim the strap PAST records we can't store, discarding real history. - // Relaunching the process re-opens Room against the restored file. Do it automatically - // rather than trust the user to read a toast (which is exactly how #57 happened). - Toast.makeText(context, "Backup restored — restarting NOOP…", Toast.LENGTH_LONG).show() - // NonCancellable: this coroutine runs in the screen's scope, which is cancelled the - // instant the user navigates away. The restart is a data-safety guarantee (the DB is - // already swapped), so it must complete even if the composition leaves — otherwise the - // user could keep syncing into the closed DB, the very bug we're fixing. - withContext(NonCancellable) { - delay(800) // let the toast render before the process dies - val ctx = context.applicationContext - ctx.packageManager.getLaunchIntentForPackage(ctx.packageName) - ?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) - ?.let { ctx.startActivity(it) } - Runtime.getRuntime().exit(0) + // Clear in a `finally` so it clears on ANY exit — the twin of the Apple `defer` in + // `BackupSyncView.runRestore`. All five controls in the screen body are gated on `!busy` + // — folder picker, auto toggle, keep-count, Back up now, Restore — so a flag left set is a + // dead end rather than one stuck button. (The restore sheet and confirm dialog are not + // gated, but they are transient overlays, not the way back to a working screen.) + // + // The NeedsRestart branch never reaches the `finally`: it exits the process first. That is + // the wanted behaviour — the screen stays disabled through the restart rather than briefly + // accepting taps against a database that has already been swapped underneath it. + try { + val r = withContext(Dispatchers.IO) { DataBackup.importFrom(context, uri) } + when (r) { + is DataBackup.ImportResult.NeedsRestart -> { + // #57: the restore CLOSED and swapped the database file. The long-lived WhoopRepository + + // BLE client still hold a DAO on the OLD (now-closed) connection, so any strap sync would + // fail with "connection pool has been closed" — and, worse, empty/metadata history ENDs + // would still ack and trim the strap PAST records we can't store, discarding real history. + // Relaunching the process re-opens Room against the restored file. Do it automatically + // rather than trust the user to read a toast (which is exactly how #57 happened). + Toast.makeText(context, "Backup restored — restarting NOOP…", Toast.LENGTH_LONG).show() + // NonCancellable: this coroutine runs in the screen's scope, which is cancelled the + // instant the user navigates away. The restart is a data-safety guarantee (the DB is + // already swapped), so it must complete even if the composition leaves — otherwise the + // user could keep syncing into the closed DB, the very bug we're fixing. + withContext(NonCancellable) { + delay(800) // let the toast render before the process dies + val ctx = context.applicationContext + ctx.packageManager.getLaunchIntentForPackage(ctx.packageName) + ?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + ?.let { ctx.startActivity(it) } + Runtime.getRuntime().exit(0) + } } + is DataBackup.ImportResult.Failed -> + Toast.makeText(context, r.message, Toast.LENGTH_LONG).show() + // #1807: recoverable, but not from here — this screen restores a folder snapshot + // directly and has no confirm step to hang the override on. Settings → Backup & restore + // → Import does, and shows the same sentence with a way through. + is DataBackup.ImportResult.TooLarge -> + Toast.makeText(context, r.message, Toast.LENGTH_LONG).show() } - is DataBackup.ImportResult.Failed -> - Toast.makeText(context, r.message, Toast.LENGTH_LONG).show() + } finally { + busy = false } } } @@ -329,18 +346,24 @@ fun BackupSyncScreen() { onClick = { busy = true scope.launch { - val ok = withContext(Dispatchers.IO) { BackupSync.backupNow(context) } - lastMs = BackupSyncPrefs.lastBackupMs(context) - busy = false - Toast.makeText( - context, - if (ok) { - "Backed up to your folder." - } else { - "Backup failed - re-pick the folder and try again." - }, - Toast.LENGTH_LONG, - ).show() + // Clear in a `finally` so it clears on ANY exit — the twin of the Apple `defer` in + // `BackupSyncView.backupNow`. The flag now clears after the toast rather than before it, + // which is the point: the screen stays disabled until the work is genuinely finished. + try { + val ok = withContext(Dispatchers.IO) { BackupSync.backupNow(context) } + lastMs = BackupSyncPrefs.lastBackupMs(context) + Toast.makeText( + context, + if (ok) { + "Backed up to your folder." + } else { + "Backup failed - re-pick the folder and try again." + }, + Toast.LENGTH_LONG, + ).show() + } finally { + busy = false + } } }, ) diff --git a/android/app/src/main/java/com/noop/ui/BodyClockDialCard.kt b/android/app/src/main/java/com/noop/ui/BodyClockDialCard.kt new file mode 100644 index 0000000000..12234120e5 --- /dev/null +++ b/android/app/src/main/java/com/noop/ui/BodyClockDialCard.kt @@ -0,0 +1,267 @@ +package com.noop.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bedtime +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.noop.R +import com.noop.analytics.CircadianEngine +import kotlin.math.cos +import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sin + +// MARK: - 24 h body-clock dial (#1680) +// +// Two concentric arcs on one ring: the outer is where the body clock wanted the night, the inner is where +// it happened. Overlap is the whole message — the card exists so "was last night's timing right" is +// answerable at a glance, which the text-only BodyClockCard on the Health screen cannot do. +// +// VOCABULARY. The caption measures sleepWindowOffsetHours — the distance between the two ARCS DRAWN — and +// NOT offsetVsScheduleMinutes, which compares the clock to the wearer's habitual schedule and is what the +// Health card already reports. The two disagree exactly when someone keeps a consistent schedule that does +// not suit their clock, and that is the case this dial exists to show, so captioning it with the other +// number would contradict the picture. +// +// Nothing here computes a metric: the window, the offset and the chronotype all come from CircadianEngine, +// byte-identical with the Swift twin. Only the drawing is per-platform (visual parity, not pixel parity). + +/** + * The dial card. [actualBedHour] / [actualWakeHour] are the night's own clock hours (0..<24, fractional), + * taken from the scored session by the caller. Twin of Apple `BodyClockDialCard`. + */ +@Composable +fun BodyClockDialCard( + estimate: CircadianEngine.PhaseEstimate, + actualBedHour: Double, + actualWakeHour: Double, +) { + // One hue for both arcs, told apart by dash and weight rather than by a second colour. Two blues + // competed with the background image; a single legible one plus a dashed, lighter reference does not. + val hue = Palette.restLine + // The night's length, taken the long way round the clock when it crosses midnight. + val durationHours = ((actualWakeHour - actualBedHour) % 24.0).let { if (it <= 0.0) it + 24.0 else it } + // null exactly when the night's length is non-positive or a full day — the same input that makes + // sweepHours wrap to 24 h and draw the actual arc as a complete ring. A full circle with no ideal arc + // beside it would state something false about the night, so the card stands down. Mirrors Swift. + val ideal = CircadianEngine.idealSleepWindow(estimate.tempMinHour, durationHours) ?: return + val offsetHours = CircadianEngine.sleepWindowOffsetHours(estimate.tempMinHour, actualWakeHour) + val alignment = alignmentText(offsetHours) + // Resolved outside the Canvas: rememberVectorPainter is a composable and cannot be called in a + // DrawScope. + val bedPainter = rememberVectorPainter(Icons.Filled.Bedtime) + val dialLabel = uiString(R.string.l10n_body_clock_dial_card_body_clock_dial_03cdbc31) + + NoopCard(tint = hue) { + Column(verticalArrangement = Arrangement.spacedBy(Metrics.gap)) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { + Column(modifier = Modifier.weight(1f)) { + Overline(uiString(R.string.l10n_body_clock_dial_card_body_clock_b0b9b988)) + Text( + uiString(R.string.l10n_body_clock_dial_card_last_night_against_your_clock_9183a37c), + style = NoopType.footnote, + color = Palette.textTertiary, + ) + } + } + + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(170.dp) + .semantics { contentDescription = dialLabel }, + ) { + val side = min(size.width, size.height) + val centre = Offset(size.width / 2f, size.height / 2f) + val outer = side / 2f - 10.dp.toPx() + val inner = outer - 16.dp.toPx() + + // A full-circle TRACK under the night arc, the same idiom RecoveryRing uses: a faint + // surfaceInset band with the live arc drawn on top. A one-dp hairline left the dial + // reading as a thin wireframe against a busy background; a real track gives the ring + // presence and makes the highlighted segment obvious as a portion of a whole day. + drawCircle(color = Palette.surfaceInset, radius = inner, center = centre, + style = Stroke(width = 9.dp.toPx(), cap = StrokeCap.Round)) + // A hairline at the reference radius so the dashed arc still has a circle to belong to. + drawCircle(color = Palette.hairline, radius = outer, center = centre, + style = Stroke(width = 1.dp.toPx())) + + // Six-hourly ticks, with MIDNIGHT drawn longer and brighter. Four identical marks at 90 + // degrees orient nothing — the ring is symmetric under rotation as far as they are + // concerned, so a reader cannot tell midnight from noon and the arcs become unplaceable. + // One distinguished mark anchors the whole dial, and does it without text, which keeps + // the card free of a 12-versus-24-hour clock-format question. + var tick = 0.0 + while (tick < 24.0) { + val isMidnight = tick == 0.0 + val a = Math.toRadians(hourAngleDegrees(tick)) + val len = if (isMidnight) 9.dp.toPx() else 4.dp.toPx() + drawLine( + color = if (isMidnight) Palette.textSecondary else Palette.textTertiary.copy(alpha = 0.5f), + start = Offset(centre.x + (cos(a) * (outer - len)).toFloat(), + centre.y + (sin(a) * (outer - len)).toFloat()), + end = Offset(centre.x + (cos(a) * outer).toFloat(), + centre.y + (sin(a) * outer).toFloat()), + strokeWidth = if (isMidnight) 1.5.dp.toPx() else 1.dp.toPx(), + ) + tick += 6.0 + } + + fun arc( + radius: Float, + from: Double, + to: Double, + colour: androidx.compose.ui.graphics.Color, + width: Float, + dashed: Boolean, + ) { + drawArc( + color = colour, + startAngle = hourAngleDegrees(from).toFloat(), + sweepAngle = (sweepHours(from, to) / 24.0 * 360.0).toFloat(), + useCenter = false, + topLeft = Offset(centre.x - radius, centre.y - radius), + size = Size(radius * 2, radius * 2), + // BUTT caps on the dashed arc, ROUND on the solid one. A round cap adds + // width/2 at EACH end of EVERY dash, so at a 7 dp stroke a [2, 7] dash renders + // as 9 dp of ink with a 0 dp gap — a line that looks solid while claiming to be + // dashed, which is the one outcome this change must not produce. With butt caps + // the numbers mean what they say. + style = Stroke( + width = width, + cap = if (dashed) StrokeCap.Butt else StrokeCap.Round, + pathEffect = if (dashed) { + PathEffect.dashPathEffect(floatArrayOf(3.dp.toPx(), 5.dp.toPx())) + } else { + null + }, + ), + ) + } + + // The two arcs differ by PATTERN as well as tint. Opacity alone was the first cut and it + // does not survive the card being translucent over a custom background image — the + // reference arc washed out to near-invisible on a real device, which loses the comparison + // the card exists for. A dash also reads without relying on colour at all. + arc(outer, ideal.bedHour, ideal.wakeHour, + hue.copy(alpha = 0.55f), 7.dp.toPx(), dashed = true) + arc(inner, actualBedHour, actualWakeHour, hue, 9.dp.toPx(), dashed = false) + + // A marker at sleep ONSET. Without it the night arc has two indistinguishable ends and + // the reader has to work out which way round the day runs before the picture means + // anything — it turns "somewhere in this band" into "it started here". Sits on the arc + // it marks, so it moves with the night rather than needing its own placement rule. + val onset = Math.toRadians(hourAngleDegrees(actualBedHour)) + val glyph = 14.dp.toPx() + translate( + left = centre.x + (cos(onset) * inner).toFloat() - glyph / 2f, + top = centre.y + (sin(onset) * inner).toFloat() - glyph / 2f, + ) { + with(bedPainter) { + draw(Size(glyph, glyph), colorFilter = ColorFilter.tint(hue)) + } + } + } + + DialLegend(hue) + + Text(alignment, style = NoopType.title2, color = Palette.textPrimary) + + CircadianEngine.chronotype(estimate)?.let { c -> + Text(chronotypeText(c), style = NoopType.footnote, color = Palette.textTertiary) + } + } + } +} + +/** + * Which arc is which. Without this the card shows two bands and no way to tell them apart — "outer means + * ideal" is an arbitrary choice, not something a reader can infer. The swatches are drawn with the SAME + * stroke style as the arcs so the mapping cannot drift apart from the drawing. Twin of Apple `legend`. + */ +@Composable +private fun DialLegend(hue: androidx.compose.ui.graphics.Color) { + Row(verticalAlignment = Alignment.CenterVertically) { + LegendItem(hue, dashed = false, label = uiString(R.string.l10n_body_clock_dial_card_last_night_6eaba4dd)) + Spacer(Modifier.width(14.dp)) + LegendItem(hue.copy(alpha = 0.55f), dashed = true, label = uiString(R.string.l10n_body_clock_dial_card_your_clock_1b91f5ee)) + } +} + +@Composable +private fun LegendItem(colour: androidx.compose.ui.graphics.Color, dashed: Boolean, label: String) { + Row(verticalAlignment = Alignment.CenterVertically) { + Canvas(modifier = Modifier.size(width = 18.dp, height = 4.dp)) { + drawLine( + color = colour, + start = Offset(0f, size.height / 2f), + end = Offset(size.width, size.height / 2f), + strokeWidth = 3.dp.toPx(), + cap = if (dashed) StrokeCap.Butt else StrokeCap.Round, + pathEffect = if (dashed) { + PathEffect.dashPathEffect(floatArrayOf(2.dp.toPx(), 3.dp.toPx())) + } else { + null + }, + ) + } + Spacer(Modifier.width(5.dp)) + Text(label, style = NoopType.footnote, color = Palette.textTertiary) + } +} + +/** A unix second as a fractional LOCAL clock hour — the dial's only input beyond the phase estimate. */ +internal fun localClockHour(ts: Long): Double { + val c = java.util.Calendar.getInstance() + c.timeInMillis = ts * 1000L + return c.get(java.util.Calendar.HOUR_OF_DAY) + c.get(java.util.Calendar.MINUTE) / 60.0 +} + +/** Midnight at the top, clocking round to the right. Compose's zero angle is at 3 o'clock, hence the −90. */ +internal fun hourAngleDegrees(hour: Double): Double = hour / 24.0 * 360.0 - 90.0 + +/** Sweep from [from] to [to] clockwise, always positive so an arc crossing midnight still draws. */ +internal fun sweepHours(from: Double, to: Double): Double = + ((to - from) % 24.0).let { if (it <= 0.0) it + 24.0 else it } + +/** + * Rounded to five minutes: the underlying phase is an activity fit, so a to-the-minute caption would imply + * a precision the estimate does not carry. + */ +@Composable +private fun alignmentText(offsetHours: Double): String { + val minutes = (offsetHours * 60 / 5).roundToInt() * 5 + if (kotlin.math.abs(minutes) < 30) return uiString(R.string.l10n_body_clock_dial_card_in_sync_with_your_body_clock_4b5181c8) + val absMinutes = kotlin.math.abs(minutes) + val amount = if (absMinutes >= 60) String.format(java.util.Locale.getDefault(), "%.1f h", absMinutes / 60.0) else "$absMinutes min" + return if (minutes > 0) uiString(R.string.l10n_body_clock_dial_card_1_s_later_than_your_body_50b7d966, amount) else uiString(R.string.l10n_body_clock_dial_card_1_s_earlier_than_your_body_68877f50, amount) +} + +@Composable +private fun chronotypeText(c: CircadianEngine.Chronotype): String = when (c) { + CircadianEngine.Chronotype.MORNING -> uiString(R.string.l10n_body_clock_dial_card_morning_type_289dd7bf) + CircadianEngine.Chronotype.INTERMEDIATE -> uiString(R.string.l10n_body_clock_dial_card_intermediate_type_a7b6b74c) + CircadianEngine.Chronotype.EVENING -> uiString(R.string.l10n_body_clock_dial_card_evening_type_3e1ce111) +} diff --git a/android/app/src/main/java/com/noop/ui/BottomBarStyle.kt b/android/app/src/main/java/com/noop/ui/BottomBarStyle.kt new file mode 100644 index 0000000000..ad4ee3e554 --- /dev/null +++ b/android/app/src/main/java/com/noop/ui/BottomBarStyle.kt @@ -0,0 +1,66 @@ +package com.noop.ui + +import kotlin.math.abs + +/** The most transparent step. */ +const val MIN_OPACITY_STEP = 1 + +/** Solid. */ +const val MAX_OPACITY_STEP = 11 + +/** + * The shipped 0.80, sitting at the exact CENTRE of the slider - five notches either side. + * + * Centred on purpose. The bar you already have is the reference point, so the useful question at the + * slider is "more see-through than now, or less?", and that reads off a thumb that starts in the middle. + * With the default at 6 of 8 it sat right of centre, which made "less transparent" look like a two-notch + * afterthought next to a five-notch run the other way. + */ +const val DEFAULT_OPACITY_STEP = 6 + +/** Unscaled - the shipped bar. */ +const val DEFAULT_SCALE = 1f + +/** The offered sizes. A short fixed list, not a slider: these are the sizes worth having. */ +val BOTTOM_BAR_SCALES: List = listOf(1f, 1.25f, 1.5f, 1.75f, 2f) + +/** + * The bar's glass alpha for an opacity step: 0.30 at step 1, the shipped 0.80 at the centre step 6, and + * 1.00 at step 11. + * + * The two halves move at DIFFERENT rates, which is deliberate and is the only way to get all three of + * the things being asked for at once. Centring 0.80 with an even rate would mean either giving up the + * faint end (a symmetric range around 0.80 bottoms out at 0.60, less see-through than this already + * offers) or pushing the solid end past 1.00, which does not exist. So the transparent half spends 0.10 + * a notch across a wide range, and the solid half spends 0.04 across a narrow one. + * + * A user does not perceive that asymmetry as unevenness. The thumb starts centred, five notches each + * way, and each notch is a visible change in the direction they moved it - which is the whole contract. + * + * The floor is 0.30 rather than 0: a fully invisible bar would still take taps, so a user could hide the + * navigation and then not find it again. 0.30 is faint enough to read as "nearly gone" while leaving the + * capsule's rim visible. + */ +fun alphaForOpacityStep(step: Int): Float { + val clamped = step.coerceIn(MIN_OPACITY_STEP, MAX_OPACITY_STEP) + return if (clamped <= DEFAULT_OPACITY_STEP) { + // 0.30 -> 0.80 across steps 1..6 + 0.30f + (clamped - MIN_OPACITY_STEP) * 0.10f + } else { + // 0.80 -> 1.00 across steps 6..11 + 0.80f + (clamped - DEFAULT_OPACITY_STEP) * 0.04f + } +} + +/** + * The nearest offered scale to [value]. + * + * Snaps rather than clamps so a stored value from a build with a DIFFERENT set of sizes - or a + * hand-edited pref - lands on something offerable instead of a size the dropdown cannot show or leave. + */ +fun nearestScale(value: Float): Float = + BOTTOM_BAR_SCALES.minByOrNull { abs(it - value) } ?: DEFAULT_SCALE + +/** The dropdown's label for a scale, e.g. "1.25x". Not translated - it is a number and a multiplier. */ +fun scaleLabel(value: Float): String = + if (value == value.toInt().toFloat()) "${value.toInt()}x" else "${value}x" diff --git a/android/app/src/main/java/com/noop/ui/BreatheScreen.kt b/android/app/src/main/java/com/noop/ui/BreatheScreen.kt index 22f3147c4d..890da28dd8 100644 --- a/android/app/src/main/java/com/noop/ui/BreatheScreen.kt +++ b/android/app/src/main/java/com/noop/ui/BreatheScreen.kt @@ -156,7 +156,7 @@ private fun currentStages(pace: PaceSelection, lockedBpm: Double?): List selectedProtocol(pace)?.stages?.filter { it.durationMs > 0 }.orEmpty() } -private fun paceSelectionLabel(pace: PaceSelection, lockedBpm: Double?): String = when (pace) { +private fun paceSelectionLabel(pace: PaceSelection): String = when (pace) { is PaceSelection.Catalog -> localizedBreathTitle(pace.id) PaceSelection.Resonance -> uiString(R.string.l10n_breathe_screen_resonance_k1l2m3n4) } @@ -446,7 +446,6 @@ fun BreatheScreen(viewModel: AppViewModel) { if (showEdu) { ProtocolEduDialog( pace = pace, - lockedBpm = lockedBpm, onDismiss = { showEdu = false }, ) } @@ -553,7 +552,7 @@ fun BreatheScreen(viewModel: AppViewModel) { verticalArrangement = Arrangement.spacedBy(18.dp), ) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Overline(paceSelectionLabel(pace, lockedBpm)) + Overline(paceSelectionLabel(pace)) Spacer(Modifier.weight(1f)) IconButton( onClick = { showEdu = true }, @@ -629,7 +628,7 @@ fun BreatheScreen(viewModel: AppViewModel) { SegmentedPillControl( items = availablePaces, selection = pace, - label = { paceSelectionLabel(it, lockedBpm) }, + label = { paceSelectionLabel(it) }, onSelect = { newPace -> if (running) stopSession() pace = newPace @@ -945,7 +944,6 @@ private fun phaseWord(phase: UiPhase, label: String?): String { @Composable private fun ProtocolEduDialog( pace: PaceSelection, - lockedBpm: Double?, onDismiss: () -> Unit, ) { val proto = selectedProtocol(pace) diff --git a/android/app/src/main/java/com/noop/ui/CaffeineLog.kt b/android/app/src/main/java/com/noop/ui/CaffeineLog.kt index cfeb74e555..75a677c354 100644 --- a/android/app/src/main/java/com/noop/ui/CaffeineLog.kt +++ b/android/app/src/main/java/com/noop/ui/CaffeineLog.kt @@ -44,6 +44,7 @@ import org.json.JSONObject import java.util.Calendar import java.util.UUID import kotlin.math.roundToInt +import com.noop.analytics.ClockFormat // MARK: - Caffeine window (#526) — pure persistence helpers + the Insights logging card. // @@ -326,8 +327,10 @@ private fun caffeineHoursLabel(hrs: Double): String { } private fun caffeineIntakeLabel(intake: CaffeineIntake, context: Context): String { - val time = android.text.format.DateFormat.getTimeFormat(context) - .format(java.util.Date(intake.atEpochSec * 1000L)) + // #1821: the reader's chosen clock, not the raw device format. + val time = java.text.SimpleDateFormat( + ClockFormat.hourMinutePattern(ClockPrefs.uses24Hour(context)), java.util.Locale.getDefault(), + ).format(java.util.Date(intake.atEpochSec * 1000L)) return if (intake.mg != null) "$time · ${intake.mg.roundToInt()} mg" else "$time · amount not logged" } diff --git a/android/app/src/main/java/com/noop/ui/Charts.kt b/android/app/src/main/java/com/noop/ui/Charts.kt index a1484c2b17..a2473aa5a0 100644 --- a/android/app/src/main/java/com/noop/ui/Charts.kt +++ b/android/app/src/main/java/com/noop/ui/Charts.kt @@ -548,6 +548,11 @@ fun BarChart( // Optional per-point display labels index-aligned with [values]; when supplied, a tap shows // "